feat(config): introduce typed configuration objects, dedicated value objects and enforce global class referencing
This commit is contained in:
parent
0063727790
commit
1df3a27c98
44 changed files with 1037 additions and 138 deletions
|
|
@ -3,11 +3,13 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use Infra\Providers\AppServiceProvider;
|
||||
use Infra\Providers\ConfigServiceProvider;
|
||||
use Infra\Providers\DomainServiceProvider;
|
||||
use Infra\Providers\EventServiceProvider;
|
||||
use Infra\Providers\SecurityServiceProvider;
|
||||
|
||||
return [
|
||||
ConfigServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
DomainServiceProvider::class,
|
||||
EventServiceProvider::class,
|
||||
|
|
|
|||
21
src/Application/Arcadia/Config/AppConfig.php
Normal file
21
src/Application/Arcadia/Config/AppConfig.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\Arcadia\Config;
|
||||
|
||||
use Shared\ValueObjects\Config\AppName;
|
||||
use Shared\ValueObjects\Config\AppTimezone;
|
||||
use Shared\ValueObjects\Config\Locale;
|
||||
|
||||
final readonly class AppConfig
|
||||
{
|
||||
public function __construct(
|
||||
public AppName $name,
|
||||
public AppTimezone $timezone,
|
||||
public Locale $locale,
|
||||
public Locale $fallbackLocale,
|
||||
public bool $isDebug,
|
||||
) {
|
||||
}
|
||||
}
|
||||
16
src/Application/Arcadia/Config/ThrottlingConfig.php
Normal file
16
src/Application/Arcadia/Config/ThrottlingConfig.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\Arcadia\Config;
|
||||
|
||||
use Shared\ValueObjects\Config\RequestsPerMinute;
|
||||
|
||||
final readonly class ThrottlingConfig
|
||||
{
|
||||
public function __construct(
|
||||
public RequestsPerMinute $webLimit,
|
||||
public RequestsPerMinute $apiV1Limit,
|
||||
) {
|
||||
}
|
||||
}
|
||||
21
src/Application/Security/Config/DevClientCredentials.php
Normal file
21
src/Application/Security/Config/DevClientCredentials.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\Security\Config;
|
||||
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientName;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
|
||||
final readonly class DevClientCredentials
|
||||
{
|
||||
public function __construct(
|
||||
public ClientId $id,
|
||||
public ClientName $name,
|
||||
public ClientIdentifier $identifier,
|
||||
public ClientSecret $secret,
|
||||
) {
|
||||
}
|
||||
}
|
||||
14
src/Application/Security/Config/HmacAuthConfig.php
Normal file
14
src/Application/Security/Config/HmacAuthConfig.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\Security\Config;
|
||||
|
||||
final readonly class HmacAuthConfig
|
||||
{
|
||||
public function __construct(
|
||||
public bool $allowDevBypass,
|
||||
public ?DevClientCredentials $devClient = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
15
src/Application/Security/Config/SecurityAlertMailConfig.php
Normal file
15
src/Application/Security/Config/SecurityAlertMailConfig.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\Security\Config;
|
||||
|
||||
use Shared\ValueObjects\Email;
|
||||
|
||||
final readonly class SecurityAlertMailConfig
|
||||
{
|
||||
public function __construct(
|
||||
public Email $recipientEmail,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,10 @@ use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogEntryId;
|
|||
use Application\Security\Ports\Directories\ClientAuditLogDirectory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Infra\Database\Models\ClientAuditLog;
|
||||
use Override;
|
||||
|
||||
final class EloquentClientAuditLogDirectory implements ClientAuditLogDirectory
|
||||
{
|
||||
#[Override]
|
||||
#[\Override]
|
||||
public function find(SecurityAuditLogEntryId $id): ?SecurityAuditLogEntry
|
||||
{
|
||||
$record = ClientAuditLog::query()
|
||||
|
|
@ -28,7 +27,7 @@ final class EloquentClientAuditLogDirectory implements ClientAuditLogDirectory
|
|||
return $this->toDomainEntity($record);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
#[\Override]
|
||||
public function findLogs(
|
||||
\DateTimeImmutable $since,
|
||||
?\DateTimeImmutable $to = null,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ namespace Infra\Casts;
|
|||
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** @implements CastsAttributes<SecurityAuditLogDetails, array<string, string>|SecurityAuditLogDetails|string|null> */
|
||||
final class SecurityAuditLogDetailsCast implements CastsAttributes
|
||||
|
|
@ -32,7 +31,7 @@ final class SecurityAuditLogDetailsCast implements CastsAttributes
|
|||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
throw new InvalidArgumentException('Details attribute must be an array, string, or null');
|
||||
throw new \InvalidArgumentException('Details attribute must be an array, string, or null');
|
||||
}
|
||||
|
||||
$values = [];
|
||||
|
|
@ -63,9 +62,7 @@ final class SecurityAuditLogDetailsCast implements CastsAttributes
|
|||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value->getValues(), JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json !== false ? $json : null;
|
||||
return $this->jsonEncode($value->getValues());
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
|
|
@ -73,25 +70,45 @@ final class SecurityAuditLogDetailsCast implements CastsAttributes
|
|||
return null;
|
||||
}
|
||||
|
||||
$stringValues = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$stringKey = (string) $k;
|
||||
if (is_bool($v)) {
|
||||
$stringValues[$stringKey] = $v ? 'true' : 'false';
|
||||
} elseif (is_scalar($v)) {
|
||||
$stringValues[$stringKey] = (string) $v;
|
||||
}
|
||||
}
|
||||
$stringValues = $this->mapArray($value);
|
||||
|
||||
$json = json_encode($stringValues, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json !== false ? $json : null;
|
||||
return $this->jsonEncode($stringValues);
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Value must be null, array, string, or instance of SecurityAuditLogDetails');
|
||||
throw new \InvalidArgumentException('Value must be null, array, string, or instance of SecurityAuditLogDetails');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $array
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function mapArray(array $array): array
|
||||
{
|
||||
$stringValues = [];
|
||||
|
||||
foreach ($array as $k => $v) {
|
||||
$stringKey = (string) $k;
|
||||
if (is_bool($v)) {
|
||||
$stringValues[$stringKey] = $v ? 'true' : 'false';
|
||||
} elseif (is_scalar($v)) {
|
||||
$stringValues[$stringKey] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $stringValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $data
|
||||
*/
|
||||
private function jsonEncode(array $data): ?string
|
||||
{
|
||||
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json !== false ? $json : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
src/Infra/Config/ArcadiaStorageConfig.php
Normal file
28
src/Infra/Config/ArcadiaStorageConfig.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Config;
|
||||
|
||||
final readonly class ArcadiaStorageConfig
|
||||
{
|
||||
public string $cacheStore;
|
||||
|
||||
public string $databaseConnection;
|
||||
|
||||
public function __construct(string $cacheStore, string $databaseConnection)
|
||||
{
|
||||
$normalizedCache = trim($cacheStore);
|
||||
if ($normalizedCache === '' || ! preg_match('/^[a-zA-Z0-9_\-]+$/', $normalizedCache)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid cache store name: "%s".', $cacheStore));
|
||||
}
|
||||
|
||||
$normalizedDb = trim($databaseConnection);
|
||||
if ($normalizedDb === '' || ! preg_match('/^[a-zA-Z0-9_\-]+$/', $normalizedDb)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid database connection name: "%s".', $databaseConnection));
|
||||
}
|
||||
|
||||
$this->cacheStore = $normalizedCache;
|
||||
$this->databaseConnection = $normalizedDb;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ namespace Infra\Database\Models;
|
|||
use Database\Factories\ArcadiaBrandFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
|
||||
/**
|
||||
* @property int $code
|
||||
|
|
@ -30,7 +30,7 @@ final class ArcadiaBrand extends Model
|
|||
#[\Override]
|
||||
public function getConnectionName(): string
|
||||
{
|
||||
return Config::string('database.arcadia_meta_model');
|
||||
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
|
||||
}
|
||||
|
||||
protected $table = 'arcadia_brands';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace Infra\Database\Models;
|
|||
use Database\Factories\EmployeeFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
|
||||
/**
|
||||
* @property string $public_employee_number
|
||||
|
|
@ -39,7 +39,7 @@ final class Employee extends Model
|
|||
#[\Override]
|
||||
public function getConnectionName(): string
|
||||
{
|
||||
return Config::string('database.arcadia_meta_model');
|
||||
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
|
||||
}
|
||||
|
||||
protected $table = 'employees';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace Infra\Database\Models;
|
|||
use Database\Factories\IncoCompoFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
|
|
@ -46,7 +46,7 @@ final class IncoCompo extends Model
|
|||
#[\Override]
|
||||
public function getConnectionName(): string
|
||||
{
|
||||
return Config::string('database.arcadia_meta_model');
|
||||
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
|
||||
}
|
||||
|
||||
protected $table = 'inco_compos';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace Infra\Database\Models;
|
|||
use Database\Factories\IncoSheetFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
|
|
@ -49,7 +49,7 @@ final class IncoSheet extends Model
|
|||
#[\Override]
|
||||
public function getConnectionName(): string
|
||||
{
|
||||
return Config::string('database.arcadia_meta_model');
|
||||
return resolve(ArcadiaStorageConfig::class)->databaseConnection;
|
||||
}
|
||||
|
||||
protected $table = 'inco_sheets';
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Infra\Listeners;
|
||||
|
||||
use Application\Security\Config\SecurityAlertMailConfig;
|
||||
use Application\Security\Events\ClientSecretGenerated;
|
||||
use Application\Security\Events\ClientSecretRegenerated;
|
||||
use Application\Security\Events\ClientStatusToggled;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
|
|
@ -23,7 +23,7 @@ final class SendClientSecurityAlertMail implements ShouldQueue
|
|||
|
||||
public function __construct(
|
||||
private readonly Mailer $mailer,
|
||||
private readonly ConfigRepository $config,
|
||||
private readonly SecurityAlertMailConfig $config,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -48,9 +48,7 @@ final class SendClientSecurityAlertMail implements ShouldQueue
|
|||
|
||||
private function getEventRecipient(): Address
|
||||
{
|
||||
$recipientValue = $this->config->get('mail.security_recipient', 'security@example.com');
|
||||
|
||||
return new Address(is_string($recipientValue) ? $recipientValue : 'security@example.com');
|
||||
return new Address($this->config->recipientEmail->value);
|
||||
}
|
||||
|
||||
private function getEventType(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): string
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Infra\Providers;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Application\Arcadia\Config\ThrottlingConfig;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request as IlluminateRequest;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
|
|
@ -19,6 +20,7 @@ use Uri\Rfc3986\Uri;
|
|||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
#[\Override]
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton('clientIp', function () {
|
||||
|
|
@ -26,9 +28,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
public function boot(AppConfig $appConfig, ThrottlingConfig $throttlingConfig): void
|
||||
{
|
||||
$this->configureRateLimiting();
|
||||
$this->configureRateLimiting($throttlingConfig);
|
||||
|
||||
ini_set('assert.active', '1');
|
||||
ini_set('assert.exception', '1');
|
||||
|
|
@ -37,19 +39,20 @@ class AppServiceProvider extends ServiceProvider
|
|||
|
||||
URL::forceScheme('https');
|
||||
|
||||
$primaryLocale = $appConfig->locale->value;
|
||||
foreach ([\LC_TIME, \LC_MESSAGES, \LC_COLLATE, \LC_CTYPE, \LC_MONETARY] as $constant) {
|
||||
setlocale(
|
||||
$constant,
|
||||
sprintf(
|
||||
'%s_%s.UTF-8',
|
||||
Config::string('app.locale'),
|
||||
strtoupper(Config::string('app.locale'))
|
||||
$primaryLocale,
|
||||
strtoupper($primaryLocale)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Date::setLocale(Config::string('app.locale'));
|
||||
Date::setFallbackLocale(Config::string('app.fallback_locale'));
|
||||
Date::setLocale($appConfig->locale->value);
|
||||
Date::setFallbackLocale($appConfig->fallbackLocale->value);
|
||||
|
||||
Request::macro('pagination', function (int $defaultPageSize = 25): Pagination {
|
||||
/** @var \Illuminate\Http\Request $this */
|
||||
|
|
@ -67,14 +70,14 @@ class AppServiceProvider extends ServiceProvider
|
|||
});
|
||||
}
|
||||
|
||||
protected function configureRateLimiting(): void
|
||||
protected function configureRateLimiting(ThrottlingConfig $throttlingConfig): void
|
||||
{
|
||||
RateLimiter::for('web', function (IlluminateRequest $_) {
|
||||
return Limit::perMinute(Config::integer('throttles.web'))->by(resolve('clientIp'));
|
||||
RateLimiter::for('web', function (IlluminateRequest $_) use ($throttlingConfig) {
|
||||
return Limit::perMinute($throttlingConfig->webLimit->toInt())->by(resolve('clientIp'));
|
||||
});
|
||||
|
||||
RateLimiter::for('api.v1', function (IlluminateRequest $_) {
|
||||
return Limit::perMinute(Config::integer('throttles.api.v1'))->by(resolve('clientIp'));
|
||||
RateLimiter::for('api.v1', function (IlluminateRequest $_) use ($throttlingConfig) {
|
||||
return Limit::perMinute($throttlingConfig->apiV1Limit->toInt())->by(resolve('clientIp'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
src/Infra/Providers/ConfigServiceProvider.php
Normal file
82
src/Infra/Providers/ConfigServiceProvider.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Providers;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Application\Arcadia\Config\ThrottlingConfig;
|
||||
use Application\Security\Config\DevClientCredentials;
|
||||
use Application\Security\Config\HmacAuthConfig;
|
||||
use Application\Security\Config\SecurityAlertMailConfig;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientName;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Shared\ValueObjects\Config\AppName;
|
||||
use Shared\ValueObjects\Config\AppTimezone;
|
||||
use Shared\ValueObjects\Config\Locale;
|
||||
use Shared\ValueObjects\Config\RequestsPerMinute;
|
||||
use Shared\ValueObjects\Email;
|
||||
|
||||
final class ConfigServiceProvider extends ServiceProvider
|
||||
{
|
||||
#[\Override]
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(SecurityAlertMailConfig::class, static function (): SecurityAlertMailConfig {
|
||||
return new SecurityAlertMailConfig(
|
||||
recipientEmail: new Email(Config::string('mail.security_recipient', 'security@example.com')),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind(HmacAuthConfig::class, static function (): HmacAuthConfig {
|
||||
$allowDevBypass = Config::boolean('auth.hmac.allow_dev_bypass', false);
|
||||
$devClient = null;
|
||||
|
||||
if (Config::has('auth.hmac.dev_client')) {
|
||||
/** @var array{id: string, name: string, identifier: string, secret: string} $devClientConfig */
|
||||
$devClientConfig = Config::array('auth.hmac.dev_client');
|
||||
$devClient = new DevClientCredentials(
|
||||
id: new ClientId(Uuid::fromString($devClientConfig['id'])),
|
||||
name: new ClientName($devClientConfig['name']),
|
||||
identifier: new ClientIdentifier($devClientConfig['identifier']),
|
||||
secret: new ClientSecret($devClientConfig['secret']),
|
||||
);
|
||||
}
|
||||
|
||||
return new HmacAuthConfig(
|
||||
allowDevBypass: $allowDevBypass,
|
||||
devClient: $devClient,
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind(ThrottlingConfig::class, static function (): ThrottlingConfig {
|
||||
return new ThrottlingConfig(
|
||||
webLimit: new RequestsPerMinute(Config::integer('throttles.web', 120)),
|
||||
apiV1Limit: new RequestsPerMinute(Config::integer('throttles.api.v1', 60)),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind(AppConfig::class, static function (): AppConfig {
|
||||
return new AppConfig(
|
||||
name: new AppName(Config::string('app.name', 'Arcadia')),
|
||||
timezone: new AppTimezone(Config::string('app.timezone', 'UTC')),
|
||||
locale: new Locale(Config::string('app.locale', 'en')),
|
||||
fallbackLocale: new Locale(Config::string('app.fallback_locale', 'en')),
|
||||
isDebug: Config::boolean('app.debug', false),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind(ArcadiaStorageConfig::class, static function (): ArcadiaStorageConfig {
|
||||
return new ArcadiaStorageConfig(
|
||||
cacheStore: Config::string('cache.arcadia_store', 'database'),
|
||||
databaseConnection: Config::string('database.arcadia_meta_model', 'arcadia'),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ use Domain\Ports\Directories\IncoCompositionDirectory;
|
|||
use Domain\Ports\Directories\IncoSheetDirectory;
|
||||
use Domain\Ports\Directories\IncoShortSheetDirectory;
|
||||
use Domain\Ports\Repositories\BrandRepository;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Infra\Adapters\Directories\EloquentArcadiaBrandDirectory;
|
||||
use Infra\Adapters\Directories\EloquentClientAuditLogDirectory;
|
||||
|
|
@ -23,6 +23,7 @@ use Infra\Adapters\Directories\EloquentIncoShortSheetDirectory;
|
|||
use Infra\Adapters\Events\LaravelTransactionalDomainEventDispatcher;
|
||||
use Infra\Adapters\Repositories\EloquentBrandRepository;
|
||||
use Infra\Cache\BrandCache;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
use Infra\Usecase\LaravelExecutor;
|
||||
use Shared\DDD\Event\DomainEventDispatcher;
|
||||
use Shared\DDD\Event\TransactionalDomainEventDispatcher;
|
||||
|
|
@ -41,10 +42,9 @@ class DomainServiceProvider extends ServiceProvider
|
|||
$this->app->bind(BrandRepository::class, EloquentBrandRepository::class);
|
||||
$this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class);
|
||||
|
||||
$this->app->bind(BrandCache::class, static function (): BrandCache {
|
||||
$storeName = Config::string('cache.arcadia_store');
|
||||
/** @var \Psr\SimpleCache\CacheInterface $cache */
|
||||
$cache = Cache::store($storeName);
|
||||
$this->app->bind(BrandCache::class, static function (Application $app): BrandCache {
|
||||
$storageConfig = $app->make(ArcadiaStorageConfig::class);
|
||||
$cache = Cache::store($storageConfig->cacheStore);
|
||||
|
||||
return new BrandCache($cache);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Infra\Providers;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Application\Security\Ports\Directories\ClientDirectory;
|
||||
use Application\Security\Ports\Repositories\ClientRepository;
|
||||
use Application\Security\Ports\Services\ClientCredentialGenerator;
|
||||
|
|
@ -13,7 +14,6 @@ use Application\Security\Ports\Services\RequestValidator;
|
|||
use Application\Security\Ports\Services\TimestampValidator;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Infra\Adapters\Repositories\ApiClientRepository;
|
||||
use Infra\Adapters\Services\CachedNonceValidator;
|
||||
|
|
@ -21,6 +21,7 @@ use Infra\Adapters\Services\ClientCredentialGeneratorImpl;
|
|||
use Infra\Adapters\Services\HmacSignatureValidatorImpl;
|
||||
use Infra\Adapters\Services\RequestValidatorImpl;
|
||||
use Infra\Adapters\Services\TimestampValidatorImpl;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
|
||||
use Presentation\Console\Security\ConsoleUserDetailsProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
|
@ -37,9 +38,8 @@ final class SecurityServiceProvider extends ServiceProvider
|
|||
$this->app->bind(HmacSignatureValidator::class, HmacSignatureValidatorImpl::class);
|
||||
|
||||
$this->app->bind(NonceValidator::class, function (Application $app): CachedNonceValidator {
|
||||
$storeName = Config::string('cache.arcadia_store');
|
||||
/** @var \Psr\SimpleCache\CacheInterface $cache */
|
||||
$cache = Cache::store($storeName);
|
||||
$storageConfig = $app->make(ArcadiaStorageConfig::class);
|
||||
$cache = Cache::store($storageConfig->cacheStore);
|
||||
|
||||
return new CachedNonceValidator(
|
||||
logger: $app->make(LoggerInterface::class),
|
||||
|
|
@ -47,11 +47,12 @@ final class SecurityServiceProvider extends ServiceProvider
|
|||
);
|
||||
});
|
||||
|
||||
$this->app->bind(TimestampValidator::class, function () {
|
||||
$timezone = new \DateTimeZone(Config::string('app.timezone'));
|
||||
$this->app->bind(TimestampValidator::class, function (Application $app): TimestampValidatorImpl {
|
||||
$appConfig = $app->make(AppConfig::class);
|
||||
|
||||
return new TimestampValidatorImpl($timezone);
|
||||
return new TimestampValidatorImpl($appConfig->timezone->toDateTimeZone());
|
||||
});
|
||||
|
||||
$this->app->bind(RequestValidator::class, RequestValidatorImpl::class);
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ namespace Presentation\Api\Exceptions;
|
|||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Presentation\Api\V1\Exceptions\ApiExceptionRenderer as V1ApiExceptionRenderer;
|
||||
use Throwable;
|
||||
|
||||
final readonly class ApiExceptionDispatcher
|
||||
{
|
||||
|
|
@ -16,7 +15,7 @@ final readonly class ApiExceptionDispatcher
|
|||
) {
|
||||
}
|
||||
|
||||
public function render(Throwable $e, Request $request): ?JsonResponse
|
||||
public function render(\Throwable $e, Request $request): ?JsonResponse
|
||||
{
|
||||
if (! $request->is('api/*', 'api') && ! $request->wantsJson()) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -4,22 +4,26 @@ declare(strict_types=1);
|
|||
|
||||
namespace Presentation\Api\V1\Exceptions;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Shared\DDD\Exception\NotFoundException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
use Uri\Rfc3986\Uri;
|
||||
|
||||
final readonly class ApiExceptionRenderer
|
||||
{
|
||||
private const CONTENT_TYPE = 'application/problem+json';
|
||||
|
||||
public function render(Throwable $e, Request $request): JsonResponse
|
||||
public function __construct(
|
||||
private AppConfig $appConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
public function render(\Throwable $e, Request $request): JsonResponse
|
||||
{
|
||||
$baseUri = $request->toUri();
|
||||
|
||||
|
|
@ -84,7 +88,7 @@ final readonly class ApiExceptionRenderer
|
|||
};
|
||||
}
|
||||
|
||||
private function renderDefault(Throwable $e, Request $request, Uri $baseUri): JsonResponse
|
||||
private function renderDefault(\Throwable $e, Request $request, Uri $baseUri): JsonResponse
|
||||
{
|
||||
$status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
|
||||
$title = Response::$statusTexts[$status] ?? 'Internal Server Error';
|
||||
|
|
@ -92,7 +96,7 @@ final readonly class ApiExceptionRenderer
|
|||
$detail = $title;
|
||||
if ($e instanceof HttpExceptionInterface && $e->getMessage() !== '') {
|
||||
$detail = $e->getMessage();
|
||||
} elseif (Config::boolean('app.debug')) {
|
||||
} elseif ($this->appConfig->isDebug) {
|
||||
$detail = $e->getMessage() ?: $title;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,15 @@ declare(strict_types=1);
|
|||
|
||||
namespace Presentation\Api\V1\Http\Middlewares;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureApiV1AcceptsJson
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
* @param \Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
// * Bypass for OpenAPI YAML spec route
|
||||
if ($request->routeIs('api.v1.openapi')) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Presentation\Api\V1\Http\Middlewares;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -12,9 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
final class SetApiV1Etag
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
* @param \Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Presentation\Api\V1\Http\Middlewares;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Presentation\Api\V1\Contracts\ApiResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -12,9 +11,9 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
final class TransformApiV1Response
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
* @param \Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Presentation\Api\V1\Http\Middlewares;
|
||||
|
||||
use Application\Security\Config\HmacAuthConfig;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientName;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Application\Security\Models\ValueObjects\Request\HttpBodyHash;
|
||||
use Application\Security\Models\ValueObjects\Request\HttpMethod;
|
||||
use Application\Security\Models\ValueObjects\Request\Nonce;
|
||||
|
|
@ -15,10 +13,8 @@ use Application\Security\Models\ValueObjects\Request\Request as SecurityRequestV
|
|||
use Application\Security\Models\ValueObjects\Request\RequestSignature;
|
||||
use Application\Security\Models\ValueObjects\Request\RequestTimestamp;
|
||||
use Application\Security\Ports\Services\RequestValidator;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Presentation\Api\V1\Contracts\ApiResponse;
|
||||
use Presentation\Api\V1\Dto\ApiClientUserDetails;
|
||||
use Presentation\Api\V1\Security\ApiUserDetailsProvider;
|
||||
|
|
@ -41,13 +37,14 @@ final readonly class VerifyHmacSignature
|
|||
private LoggerInterface $logger,
|
||||
private RequestValidator $requestValidator,
|
||||
private ApiUserDetailsProvider $userDetailsProvider,
|
||||
private HmacAuthConfig $config,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
* @param \Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
if ($this->shouldBypassHmac()) {
|
||||
return $this->handleBypassedRequest($request, $next);
|
||||
|
|
@ -102,27 +99,29 @@ final readonly class VerifyHmacSignature
|
|||
return false;
|
||||
}
|
||||
|
||||
return Config::boolean('auth.hmac.allow_dev_bypass', false);
|
||||
return $this->config->allowDevBypass;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
* @param \Closure(Request): Response $next
|
||||
*/
|
||||
private function handleBypassedRequest(Request $request, Closure $next): Response
|
||||
private function handleBypassedRequest(Request $request, \Closure $next): Response
|
||||
{
|
||||
$this->logger->debug('HMAC verification bypassed in local/testing environment.', [
|
||||
'requestUri' => $request->getUri(),
|
||||
'requestMethod' => $request->getMethod(),
|
||||
]);
|
||||
|
||||
/** @var array{id: string, name: string, identifier: string, secret: string} $devClientConfig */
|
||||
$devClientConfig = Config::array('auth.hmac.dev_client');
|
||||
$devClient = $this->config->devClient;
|
||||
if ($devClient === null) {
|
||||
throw new \LogicException('Dev client credentials must be configured when HMAC dev bypass is enabled.');
|
||||
}
|
||||
|
||||
$devUserDetails = new ApiClientUserDetails(
|
||||
clientId: new ClientId(Uuid::fromString($devClientConfig['id'])),
|
||||
clientIdentifier: new ClientIdentifier($devClientConfig['identifier']),
|
||||
clientName: new ClientName($devClientConfig['name']),
|
||||
clientSecret: new ClientSecret($devClientConfig['secret']),
|
||||
clientId: $devClient->id,
|
||||
clientIdentifier: $devClient->identifier,
|
||||
clientName: $devClient->name,
|
||||
clientSecret: $devClient->secret,
|
||||
);
|
||||
|
||||
$this->userDetailsProvider->setCurrentUser($devUserDetails);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ namespace Presentation\Web\Exceptions;
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Shared\DDD\Exception\NotFoundException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class WebExceptionRenderer
|
||||
{
|
||||
public function render(Throwable $e, Request $request): ?Response
|
||||
public function render(\Throwable $e, Request $request): ?Response
|
||||
{
|
||||
if ($request->is('api/*', 'api') || $request->wantsJson()) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
@extends('layouts.minimal')
|
||||
|
||||
@section('title', Config::string('app.name'))
|
||||
@section('title', resolve(\Application\Arcadia\Config\AppConfig::class)->name->value)
|
||||
@section('code', 'API')
|
||||
@section('message', 'Arcadia API is up and running')
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shared\DDD\Usecase\Builder;
|
||||
|
||||
use LogicException;
|
||||
use Shared\DDD\Usecase\Input;
|
||||
use Shared\DDD\Usecase\Presenter;
|
||||
use Shared\DDD\Usecase\Usecase;
|
||||
|
|
@ -52,7 +51,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
|
|||
public function from(Input $input): BuilderStepPresenter
|
||||
{
|
||||
if ($this->usecase === null) {
|
||||
throw new LogicException('Usecase must be set before input.');
|
||||
throw new \LogicException('Usecase must be set before input.');
|
||||
}
|
||||
|
||||
$this->input = $input;
|
||||
|
|
@ -70,7 +69,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
|
|||
public function with(Presenter $presenter): BuilderStepBuild
|
||||
{
|
||||
if ($this->usecase === null || $this->input === null) {
|
||||
throw new LogicException('Usecase and input must be set before presenter.');
|
||||
throw new \LogicException('Usecase and input must be set before presenter.');
|
||||
}
|
||||
|
||||
/** @var Presenter<\Shared\DDD\Usecase\Output, object> $presenter */
|
||||
|
|
@ -84,7 +83,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
|
|||
public function run(): object
|
||||
{
|
||||
if ($this->usecase === null || $this->input === null) {
|
||||
throw new LogicException('Usecase and input are required.');
|
||||
throw new \LogicException('Usecase and input are required.');
|
||||
}
|
||||
|
||||
/** @var TView */
|
||||
|
|
|
|||
36
src/Shared/ValueObjects/Config/AppName.php
Normal file
36
src/Shared/ValueObjects/Config/AppName.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\DDD\Model\Equatable;
|
||||
|
||||
final readonly class AppName implements \Stringable, Equatable
|
||||
{
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
$normalized = trim($value);
|
||||
|
||||
if ($normalized === '') {
|
||||
throw new \InvalidArgumentException('Application name cannot be empty.');
|
||||
}
|
||||
|
||||
$this->value = $normalized;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function equals(mixed $other): bool
|
||||
{
|
||||
return $other instanceof self
|
||||
&& $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
47
src/Shared/ValueObjects/Config/AppTimezone.php
Normal file
47
src/Shared/ValueObjects/Config/AppTimezone.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\DDD\Model\Equatable;
|
||||
|
||||
final readonly class AppTimezone implements \Stringable, Equatable
|
||||
{
|
||||
public string $name;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
$trimmed = trim($name);
|
||||
|
||||
try {
|
||||
$tz = new \DateTimeZone($trimmed);
|
||||
$this->name = $tz->getName();
|
||||
} catch (\Exception) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid timezone identifier: "%s".', $name));
|
||||
}
|
||||
}
|
||||
|
||||
public static function utc(): self
|
||||
{
|
||||
return new self('UTC');
|
||||
}
|
||||
|
||||
public function toDateTimeZone(): \DateTimeZone
|
||||
{
|
||||
return new \DateTimeZone($this->name);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function equals(mixed $other): bool
|
||||
{
|
||||
return $other instanceof self
|
||||
&& $this->name === $other->name;
|
||||
}
|
||||
}
|
||||
48
src/Shared/ValueObjects/Config/Locale.php
Normal file
48
src/Shared/ValueObjects/Config/Locale.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\DDD\Model\Equatable;
|
||||
|
||||
final readonly class Locale implements \Stringable, Equatable
|
||||
{
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
$normalized = trim($value);
|
||||
|
||||
if (! preg_match('/^[a-zA-Z]{2}(?:[_\-][a-zA-Z0-9]{2,})?$/', $normalized)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid locale format: "%s".', $value));
|
||||
}
|
||||
|
||||
$this->value = $normalized;
|
||||
}
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function primaryLanguage(): string
|
||||
{
|
||||
$parts = preg_split('/[_\-]/', $this->value);
|
||||
|
||||
return $parts !== false && isset($parts[0]) ? strtolower($parts[0]) : $this->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function equals(mixed $other): bool
|
||||
{
|
||||
return $other instanceof self
|
||||
&& $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
41
src/Shared/ValueObjects/Config/RequestsPerMinute.php
Normal file
41
src/Shared/ValueObjects/Config/RequestsPerMinute.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\DDD\Model\Equatable;
|
||||
|
||||
final readonly class RequestsPerMinute implements \Stringable, Equatable
|
||||
{
|
||||
public function __construct(
|
||||
public int $value,
|
||||
) {
|
||||
if ($this->value < 1) {
|
||||
throw new \InvalidArgumentException(sprintf('Requests per minute must be at least 1, got %d.', $this->value));
|
||||
}
|
||||
}
|
||||
|
||||
public function toInt(): int
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function isGreaterThan(self $other): bool
|
||||
{
|
||||
return $this->value > $other->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string) $this->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function equals(mixed $other): bool
|
||||
{
|
||||
return $other instanceof self
|
||||
&& $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
36
src/Shared/ValueObjects/Email.php
Normal file
36
src/Shared/ValueObjects/Email.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\ValueObjects;
|
||||
|
||||
use Shared\DDD\Model\Equatable;
|
||||
|
||||
final readonly class Email implements \Stringable, Equatable
|
||||
{
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
$normalized = trim($value);
|
||||
|
||||
if (! filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid email address: "%s".', $value));
|
||||
}
|
||||
|
||||
$this->value = mb_strtolower($normalized, 'UTF-8');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function equals(mixed $other): bool
|
||||
{
|
||||
return $other instanceof self
|
||||
&& $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
|
|
@ -111,13 +111,25 @@ describe('Coding Standards & Project Rules', function (): void {
|
|||
}
|
||||
});
|
||||
|
||||
it('ensures every Unit and Integration test file contains a covers() call', function (): void {
|
||||
foreach (getTestFiles(['tests/Unit', 'tests/Integration']) as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
expect($content)->toMatch(
|
||||
'/^\s*covers\s*\(/m',
|
||||
"Test file [{$file}] is missing a covers(...) declaration."
|
||||
);
|
||||
it('ensures native classes in global namespace are not imported with use statements in src', function (): void {
|
||||
$srcDir = dirname(__DIR__, 2) . '/src';
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($srcDir, \RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||||
$content = (string) file_get_contents($file->getPathname());
|
||||
if (preg_match('/^use\s+([A-Z][a-zA-Z0-9_]*)\s*;/m', $content, $matches)) {
|
||||
$className = $matches[1];
|
||||
if (class_exists('\\' . $className) || interface_exists('\\' . $className)) {
|
||||
expect(true)->toBeFalse(
|
||||
sprintf('File [%s] imports global native class [%s] with a use statement. Use [\\%s] directly instead.', $file->getPathname(), $className, $className)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration\Infra\Providers;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Application\Arcadia\Config\ThrottlingConfig;
|
||||
use Application\Security\Config\HmacAuthConfig;
|
||||
use Application\Security\Config\SecurityAlertMailConfig;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
use Infra\Providers\ConfigServiceProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
covers(ConfigServiceProvider::class);
|
||||
|
||||
describe('ConfigServiceProvider Integration Tests', function (): void {
|
||||
it('resolves SecurityAlertMailConfig with typed Email VO', function (): void {
|
||||
// WHEN
|
||||
$config = resolve(SecurityAlertMailConfig::class);
|
||||
|
||||
// THEN
|
||||
expect($config)->toBeInstanceOf(SecurityAlertMailConfig::class)
|
||||
->and($config->recipientEmail->value)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('resolves HmacAuthConfig with dev client credentials', function (): void {
|
||||
// WHEN
|
||||
$config = resolve(HmacAuthConfig::class);
|
||||
|
||||
// THEN
|
||||
expect($config)->toBeInstanceOf(HmacAuthConfig::class)
|
||||
->and($config->devClient)->not->toBeNull()
|
||||
->and($config->devClient?->name->value)->toBe('Arcadia Dev Test Client');
|
||||
});
|
||||
|
||||
it('resolves ThrottlingConfig with typed RequestsPerMinute VOs', function (): void {
|
||||
// WHEN
|
||||
$config = resolve(ThrottlingConfig::class);
|
||||
|
||||
// THEN
|
||||
expect($config)->toBeInstanceOf(ThrottlingConfig::class)
|
||||
->and($config->webLimit->toInt())->toBeGreaterThanOrEqual(1)
|
||||
->and($config->apiV1Limit->toInt())->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('resolves AppConfig with typed timezone and locale VOs', function (): void {
|
||||
// WHEN
|
||||
$config = resolve(AppConfig::class);
|
||||
|
||||
// THEN
|
||||
expect($config)->toBeInstanceOf(AppConfig::class)
|
||||
->and($config->name->value)->not->toBeEmpty()
|
||||
->and($config->timezone->name)->toBe('UTC')
|
||||
->and($config->locale->value())->not->toBeEmpty()
|
||||
->and($config->fallbackLocale->value())->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('resolves ArcadiaStorageConfig with database and cache settings', function (): void {
|
||||
// WHEN
|
||||
$config = resolve(ArcadiaStorageConfig::class);
|
||||
|
||||
// THEN
|
||||
expect($config)->toBeInstanceOf(ArcadiaStorageConfig::class)
|
||||
->and($config->cacheStore)->not->toBeEmpty()
|
||||
->and($config->databaseConnection)->not->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Tests\Integration\Presentation\Api;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Presentation\Api\Exceptions\ApiExceptionDispatcher;
|
||||
use Presentation\Api\V1\Exceptions\ApiExceptionRenderer as V1ApiExceptionRenderer;
|
||||
|
|
@ -14,7 +15,9 @@ covers(ApiExceptionDispatcher::class);
|
|||
describe('API Exception Dispatcher Integration', function (): void {
|
||||
it('returns null for non-API web requests that do not accept JSON', function (): void {
|
||||
// GIVEN
|
||||
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new V1ApiExceptionRenderer($config);
|
||||
$dispatcher = new ApiExceptionDispatcher($renderer);
|
||||
$request = Request::create('/some-web-route', 'GET');
|
||||
$exception = new NotFoundException('Not found');
|
||||
|
||||
|
|
@ -27,7 +30,9 @@ describe('API Exception Dispatcher Integration', function (): void {
|
|||
|
||||
it('dispatches api/v1 requests to V1ApiExceptionRenderer', function (): void {
|
||||
// GIVEN
|
||||
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new V1ApiExceptionRenderer($config);
|
||||
$dispatcher = new ApiExceptionDispatcher($renderer);
|
||||
$request = Request::create('http://localhost/api/v1/employees/999', 'GET');
|
||||
$exception = new NotFoundException('Employee not found');
|
||||
|
||||
|
|
@ -38,44 +43,41 @@ describe('API Exception Dispatcher Integration', function (): void {
|
|||
expect($response)->not->toBeNull()
|
||||
->and($response?->getStatusCode())->toBe(404)
|
||||
->and($response?->headers->get('Content-Type'))->toBe('application/problem+json');
|
||||
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode((string) $response?->getContent(), true);
|
||||
expect($data['status'])->toBe(404)
|
||||
->and($data['title'])->toBe('Resource Not Found')
|
||||
->and($data['detail'])->toBe('Employee not found');
|
||||
});
|
||||
|
||||
it('falls back to V1ApiExceptionRenderer for unversioned api requests', function (): void {
|
||||
// GIVEN
|
||||
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
|
||||
$request = Request::create('http://localhost/api/ping', 'GET');
|
||||
$exception = new \DomainException('Domain invariant error');
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new V1ApiExceptionRenderer($config);
|
||||
$dispatcher = new ApiExceptionDispatcher($renderer);
|
||||
$request = Request::create('http://localhost/api/unknown-endpoint', 'GET');
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
$exception = new NotFoundException('Resource not found');
|
||||
|
||||
// WHEN
|
||||
$response = $dispatcher->render($exception, $request);
|
||||
|
||||
// THEN
|
||||
expect($response)->not->toBeNull()
|
||||
->and($response?->getStatusCode())->toBe(422);
|
||||
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode((string) $response?->getContent(), true);
|
||||
expect($data['status'])->toBe(422)
|
||||
->and($data['detail'])->toBe('Domain invariant error');
|
||||
->and($response?->getStatusCode())->toBe(404)
|
||||
->and($response?->headers->get('Content-Type'))->toBe('application/problem+json');
|
||||
});
|
||||
|
||||
it('dispatches to V1ApiExceptionRenderer when request accepts JSON even on non-api path', function (): void {
|
||||
// GIVEN
|
||||
$dispatcher = new ApiExceptionDispatcher(new V1ApiExceptionRenderer());
|
||||
$request = Request::create('http://localhost/custom-endpoint', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']);
|
||||
$exception = new NotFoundException('Custom endpoint not found');
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new V1ApiExceptionRenderer($config);
|
||||
$dispatcher = new ApiExceptionDispatcher($renderer);
|
||||
$request = Request::create('http://localhost/something-else', 'GET');
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
$exception = new NotFoundException('Something not found');
|
||||
|
||||
// WHEN
|
||||
$response = $dispatcher->render($exception, $request);
|
||||
|
||||
// THEN
|
||||
expect($response)->not->toBeNull()
|
||||
->and($response?->getStatusCode())->toBe(404);
|
||||
->and($response?->getStatusCode())->toBe(404)
|
||||
->and($response?->headers->get('Content-Type'))->toBe('application/problem+json');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Tests\Integration\Presentation\Api\V1;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
|
@ -17,7 +18,8 @@ covers(ApiExceptionRenderer::class);
|
|||
describe('V1 API Exception Renderer Integration', function (): void {
|
||||
it('renders NotFoundException as 404 Problem Details JSON', function (): void {
|
||||
// GIVEN
|
||||
$renderer = new ApiExceptionRenderer();
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new ApiExceptionRenderer($config);
|
||||
$request = Request::create('http://localhost/api/v1/employees/999', 'GET');
|
||||
$exception = new NotFoundException('Employee with ID 999 not found.');
|
||||
|
||||
|
|
@ -39,7 +41,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
|
|||
|
||||
it('renders ValidationException as 422 Problem Details JSON with invalidParams', function (): void {
|
||||
// GIVEN
|
||||
$renderer = new ApiExceptionRenderer();
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new ApiExceptionRenderer($config);
|
||||
$request = Request::create('http://localhost/api/v1/employees', 'POST');
|
||||
$validator = validator(['email' => 'invalid'], ['email' => 'email|required']);
|
||||
|
||||
|
|
@ -64,7 +67,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
|
|||
|
||||
it('renders DomainException as 422 Problem Details JSON', function (): void {
|
||||
// GIVEN
|
||||
$renderer = new ApiExceptionRenderer();
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new ApiExceptionRenderer($config);
|
||||
$request = Request::create('http://localhost/api/v1/employees', 'POST');
|
||||
$exception = new \DomainException('Invalid employee number format');
|
||||
|
||||
|
|
@ -85,7 +89,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
|
|||
|
||||
it('renders ThrottleRequestsException as 429 Problem Details JSON with rate headers', function (): void {
|
||||
// GIVEN
|
||||
$renderer = new ApiExceptionRenderer();
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new ApiExceptionRenderer($config);
|
||||
$request = Request::create('http://localhost/api/v1/employees', 'GET');
|
||||
$exception = new ThrottleRequestsException('Too Many Attempts.', null, ['Retry-After' => '60']);
|
||||
|
||||
|
|
@ -105,7 +110,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
|
|||
|
||||
it('renders HttpExceptionInterface exception with status code and detail', function (): void {
|
||||
// GIVEN
|
||||
$renderer = new ApiExceptionRenderer();
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new ApiExceptionRenderer($config);
|
||||
$request = Request::create('http://localhost/api/v1/employees', 'DELETE');
|
||||
$exception = new AccessDeniedHttpException('Access denied');
|
||||
|
||||
|
|
@ -126,7 +132,8 @@ describe('V1 API Exception Renderer Integration', function (): void {
|
|||
it('renders generic Throwable as 500 Problem Details JSON with debug info based on config', function (): void {
|
||||
// GIVEN
|
||||
Config::set('app.debug', true);
|
||||
$renderer = new ApiExceptionRenderer();
|
||||
$config = resolve(AppConfig::class);
|
||||
$renderer = new ApiExceptionRenderer($config);
|
||||
$request = Request::create('http://localhost/api/v1/employees', 'GET');
|
||||
$exception = new \RuntimeException('Database connection failure');
|
||||
|
||||
|
|
|
|||
93
tests/Unit/Application/Arcadia/Config/ArcadiaConfigsTest.php
Normal file
93
tests/Unit/Application/Arcadia/Config/ArcadiaConfigsTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Application\Arcadia\Config;
|
||||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Application\Arcadia\Config\ThrottlingConfig;
|
||||
use Infra\Config\ArcadiaStorageConfig;
|
||||
use Shared\ValueObjects\Config\AppName;
|
||||
use Shared\ValueObjects\Config\AppTimezone;
|
||||
use Shared\ValueObjects\Config\Locale;
|
||||
use Shared\ValueObjects\Config\RequestsPerMinute;
|
||||
|
||||
covers(
|
||||
ThrottlingConfig::class,
|
||||
AppConfig::class,
|
||||
ArcadiaStorageConfig::class,
|
||||
);
|
||||
|
||||
describe('Arcadia Config Objects Unit Tests', function (): void {
|
||||
it('instantiates ThrottlingConfig with typed RequestsPerMinute VOs', function (): void {
|
||||
// GIVEN
|
||||
$webLimit = new RequestsPerMinute(120);
|
||||
$apiV1Limit = new RequestsPerMinute(60);
|
||||
|
||||
// WHEN
|
||||
$config = new ThrottlingConfig(
|
||||
webLimit: $webLimit,
|
||||
apiV1Limit: $apiV1Limit,
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect($config->webLimit->toInt())->toBe(120)
|
||||
->and($config->apiV1Limit->toInt())->toBe(60);
|
||||
});
|
||||
|
||||
it('instantiates AppConfig with typed name, timezone and locale VOs', function (): void {
|
||||
// GIVEN
|
||||
$config = new AppConfig(
|
||||
name: new AppName('Arcadia API'),
|
||||
timezone: AppTimezone::utc(),
|
||||
locale: new Locale('fr'),
|
||||
fallbackLocale: new Locale('en'),
|
||||
isDebug: true,
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect($config->name->value)->toBe('Arcadia API')
|
||||
->and($config->timezone->name)->toBe('UTC')
|
||||
->and($config->locale->value())->toBe('fr')
|
||||
->and($config->fallbackLocale->value())->toBe('en')
|
||||
->and($config->isDebug)->toBeTrue();
|
||||
});
|
||||
|
||||
it('instantiates ArcadiaStorageConfig with valid connection and store names', function (): void {
|
||||
// GIVEN
|
||||
$config = new ArcadiaStorageConfig(
|
||||
cacheStore: 'redis',
|
||||
databaseConnection: 'arcadia_custom',
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect($config->cacheStore)->toBe('redis')
|
||||
->and($config->databaseConnection)->toBe('arcadia_custom');
|
||||
});
|
||||
|
||||
it('rejects invalid cache store in ArcadiaStorageConfig', function (string $invalidStore): void {
|
||||
// GIVEN & WHEN & THEN
|
||||
expect(fn () => new ArcadiaStorageConfig(
|
||||
cacheStore: $invalidStore,
|
||||
databaseConnection: 'arcadia',
|
||||
))->toThrow(\InvalidArgumentException::class);
|
||||
})->with([
|
||||
'',
|
||||
' ',
|
||||
'invalid store name with spaces',
|
||||
'invalid$store',
|
||||
]);
|
||||
|
||||
it('rejects invalid database connection in ArcadiaStorageConfig', function (string $invalidDb): void {
|
||||
// GIVEN & WHEN & THEN
|
||||
expect(fn () => new ArcadiaStorageConfig(
|
||||
cacheStore: 'redis',
|
||||
databaseConnection: $invalidDb,
|
||||
))->toThrow(\InvalidArgumentException::class);
|
||||
})->with([
|
||||
'',
|
||||
' ',
|
||||
'invalid connection with spaces',
|
||||
'invalid;connection',
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Application\Security\Config;
|
||||
|
||||
use Application\Security\Config\DevClientCredentials;
|
||||
use Application\Security\Config\HmacAuthConfig;
|
||||
use Application\Security\Config\SecurityAlertMailConfig;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\ClientName;
|
||||
use Application\Security\Models\ValueObjects\ClientSecret;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Shared\ValueObjects\Email;
|
||||
|
||||
covers(
|
||||
DevClientCredentials::class,
|
||||
HmacAuthConfig::class,
|
||||
SecurityAlertMailConfig::class,
|
||||
);
|
||||
|
||||
describe('Security Config Objects Unit Tests', function (): void {
|
||||
it('instantiates DevClientCredentials and HmacAuthConfig', function (): void {
|
||||
// GIVEN
|
||||
$id = new ClientId(Uuid::uuid7());
|
||||
$name = new ClientName('Test Dev Client');
|
||||
$identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
|
||||
$secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 88));
|
||||
|
||||
$credentials = new DevClientCredentials(
|
||||
id: $id,
|
||||
name: $name,
|
||||
identifier: $identifier,
|
||||
secret: $secret,
|
||||
);
|
||||
|
||||
$hmacConfig = new HmacAuthConfig(
|
||||
allowDevBypass: true,
|
||||
devClient: $credentials,
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect($hmacConfig->allowDevBypass)->toBeTrue()
|
||||
->and($hmacConfig->devClient)->toBe($credentials)
|
||||
->and($hmacConfig->devClient?->name->value)->toBe('Test Dev Client')
|
||||
->and($hmacConfig->devClient?->identifier->value)->toBe($identifier->value);
|
||||
});
|
||||
|
||||
it('instantiates SecurityAlertMailConfig with typed Email VO', function (): void {
|
||||
// GIVEN
|
||||
$email = new Email('security-team@arcadia.com');
|
||||
$config = new SecurityAlertMailConfig($email);
|
||||
|
||||
// THEN
|
||||
expect($config->recipientEmail->value)->toBe('security-team@arcadia.com');
|
||||
});
|
||||
});
|
||||
|
|
@ -4,16 +4,17 @@ declare(strict_types=1);
|
|||
|
||||
namespace Tests\Unit\Infra\Listeners;
|
||||
|
||||
use Application\Security\Config\SecurityAlertMailConfig;
|
||||
use Application\Security\Events\ClientSecretGenerated;
|
||||
use Application\Security\Events\ClientSecretRegenerated;
|
||||
use Application\Security\Events\ClientStatusToggled;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Infra\Listeners\SendClientSecurityAlertMail;
|
||||
use Infra\Mail\ClientSecurityAlertMail;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Shared\ValueObjects\Email;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
|
@ -36,8 +37,8 @@ describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
|
|||
generatedAt: $generatedAt,
|
||||
);
|
||||
|
||||
$configMock = resolve(ConfigRepository::class);
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
|
||||
$config = new SecurityAlertMailConfig(new Email('security@example.com'));
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $config);
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
|
@ -61,8 +62,8 @@ describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
|
|||
generatedAt: $regeneratedAt,
|
||||
);
|
||||
|
||||
$configMock = resolve(ConfigRepository::class);
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
|
||||
$config = new SecurityAlertMailConfig(new Email('security@example.com'));
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $config);
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
|
@ -87,8 +88,8 @@ describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
|
|||
toggledAt: $toggledAt,
|
||||
);
|
||||
|
||||
$configMock = resolve(ConfigRepository::class);
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
|
||||
$config = new SecurityAlertMailConfig(new Email('security@example.com'));
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $config);
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
|
|
|||
42
tests/Unit/Shared/ValueObjects/Config/AppNameTest.php
Normal file
42
tests/Unit/Shared/ValueObjects/Config/AppNameTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\ValueObjects\Config\AppName;
|
||||
|
||||
covers(AppName::class);
|
||||
|
||||
describe('AppName Value Object Unit Tests', function (): void {
|
||||
it('creates valid application name', function (): void {
|
||||
// GIVEN
|
||||
$name = new AppName(' Arcadia API ');
|
||||
|
||||
// THEN
|
||||
expect($name->value)->toBe('Arcadia API')
|
||||
->and((string) $name)->toBe('Arcadia API');
|
||||
});
|
||||
|
||||
it('rejects empty application name', function (string $invalid): void {
|
||||
// GIVEN & WHEN & THEN
|
||||
expect(fn () => new AppName($invalid))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
})->with([
|
||||
'',
|
||||
' ',
|
||||
"\t\n",
|
||||
]);
|
||||
|
||||
it('handles equality properly', function (): void {
|
||||
// GIVEN
|
||||
$name1 = new AppName('Arcadia API');
|
||||
$name2 = new AppName('Arcadia API');
|
||||
$name3 = new AppName('Other API');
|
||||
|
||||
// THEN
|
||||
expect($name1->equals($name2))->toBeTrue()
|
||||
->and($name1->equals($name3))->toBeFalse()
|
||||
->and($name1->equals('Arcadia API'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
54
tests/Unit/Shared/ValueObjects/Config/AppTimezoneTest.php
Normal file
54
tests/Unit/Shared/ValueObjects/Config/AppTimezoneTest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\ValueObjects\Config\AppTimezone;
|
||||
|
||||
covers(AppTimezone::class);
|
||||
|
||||
describe('AppTimezone Value Object Unit Tests', function (): void {
|
||||
it('creates valid timezone from name', function (): void {
|
||||
// GIVEN
|
||||
$tz = new AppTimezone('Europe/Paris');
|
||||
|
||||
// THEN
|
||||
expect($tz->name)->toBe('Europe/Paris')
|
||||
->and((string) $tz)->toBe('Europe/Paris')
|
||||
->and($tz->toDateTimeZone())->toBeInstanceOf(\DateTimeZone::class)
|
||||
->and($tz->toDateTimeZone()->getName())->toBe('Europe/Paris');
|
||||
});
|
||||
|
||||
it('creates default UTC timezone', function (): void {
|
||||
// GIVEN
|
||||
$tz = AppTimezone::utc();
|
||||
|
||||
// THEN
|
||||
expect($tz->name)->toBe('UTC')
|
||||
->and((string) $tz)->toBe('UTC');
|
||||
});
|
||||
|
||||
it('rejects invalid timezone names', function (string $invalid): void {
|
||||
// GIVEN & WHEN & THEN
|
||||
expect(fn () => new AppTimezone($invalid))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
})->with([
|
||||
'',
|
||||
' ',
|
||||
'Invalid/Timezone_Name',
|
||||
'Moon/Base',
|
||||
]);
|
||||
|
||||
it('handles equality properly', function (): void {
|
||||
// GIVEN
|
||||
$tz1 = new AppTimezone('UTC');
|
||||
$tz2 = AppTimezone::utc();
|
||||
$tz3 = new AppTimezone('Europe/Paris');
|
||||
|
||||
// THEN
|
||||
expect($tz1->equals($tz2))->toBeTrue()
|
||||
->and($tz1->equals($tz3))->toBeFalse()
|
||||
->and($tz1->equals('UTC'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
51
tests/Unit/Shared/ValueObjects/Config/LocaleTest.php
Normal file
51
tests/Unit/Shared/ValueObjects/Config/LocaleTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\ValueObjects\Config\Locale;
|
||||
|
||||
covers(Locale::class);
|
||||
|
||||
describe('Locale Value Object Unit Tests', function (): void {
|
||||
it('creates valid locale', function (string $raw, string $expectedPrimary): void {
|
||||
// WHEN
|
||||
$locale = new Locale($raw);
|
||||
|
||||
// THEN
|
||||
expect($locale->value())->toBe(trim($raw))
|
||||
->and((string) $locale)->toBe(trim($raw))
|
||||
->and($locale->primaryLanguage())->toBe($expectedPrimary);
|
||||
})->with([
|
||||
['fr', 'fr'],
|
||||
['en', 'en'],
|
||||
['fr_FR', 'fr'],
|
||||
['en_US', 'en'],
|
||||
['en-GB', 'en'],
|
||||
]);
|
||||
|
||||
it('rejects invalid locale formats', function (string $invalid): void {
|
||||
// GIVEN & WHEN & THEN
|
||||
expect(fn () => new Locale($invalid))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
})->with([
|
||||
'',
|
||||
' ',
|
||||
'f',
|
||||
'123',
|
||||
'invalid_locale_format_too_long',
|
||||
]);
|
||||
|
||||
it('handles equality properly', function (): void {
|
||||
// GIVEN
|
||||
$locale1 = new Locale('fr');
|
||||
$locale2 = new Locale('fr');
|
||||
$locale3 = new Locale('en');
|
||||
|
||||
// THEN
|
||||
expect($locale1->equals($locale2))->toBeTrue()
|
||||
->and($locale1->equals($locale3))->toBeFalse()
|
||||
->and($locale1->equals('fr'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Shared\ValueObjects\Config;
|
||||
|
||||
use Shared\ValueObjects\Config\RequestsPerMinute;
|
||||
|
||||
covers(RequestsPerMinute::class);
|
||||
|
||||
describe('RequestsPerMinute Value Object Unit Tests', function (): void {
|
||||
it('creates valid requests per minute', function (): void {
|
||||
// GIVEN
|
||||
$rate = new RequestsPerMinute(60);
|
||||
|
||||
// THEN
|
||||
expect($rate->value)->toBe(60)
|
||||
->and($rate->toInt())->toBe(60)
|
||||
->and((string) $rate)->toBe('60');
|
||||
});
|
||||
|
||||
it('rejects values less than 1', function (int $invalid): void {
|
||||
// GIVEN & WHEN & THEN
|
||||
expect(fn () => new RequestsPerMinute($invalid))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
})->with([0, -1, -100]);
|
||||
|
||||
it('compares with another RequestsPerMinute instance', function (): void {
|
||||
// GIVEN
|
||||
$rate60 = new RequestsPerMinute(60);
|
||||
$rate120 = new RequestsPerMinute(120);
|
||||
|
||||
// THEN
|
||||
expect($rate120->isGreaterThan($rate60))->toBeTrue()
|
||||
->and($rate60->isGreaterThan($rate120))->toBeFalse()
|
||||
->and($rate60->equals(new RequestsPerMinute(60)))->toBeTrue()
|
||||
->and($rate60->equals($rate120))->toBeFalse();
|
||||
});
|
||||
});
|
||||
49
tests/Unit/Shared/ValueObjects/EmailTest.php
Normal file
49
tests/Unit/Shared/ValueObjects/EmailTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Shared\ValueObjects;
|
||||
|
||||
use Shared\ValueObjects\Email;
|
||||
|
||||
covers(Email::class);
|
||||
|
||||
describe('Email Value Object Unit Tests', function (): void {
|
||||
it('creates a valid normalized email', function (): void {
|
||||
// GIVEN
|
||||
$rawEmail = ' Admin.Security@Example.COM ';
|
||||
|
||||
// WHEN
|
||||
$email = new Email($rawEmail);
|
||||
|
||||
// THEN
|
||||
expect($email->value)->toBe('admin.security@example.com')
|
||||
->and((string) $email)->toBe('admin.security@example.com');
|
||||
});
|
||||
|
||||
it('rejects invalid email formats', function (string $invalid): void {
|
||||
// GIVEN & WHEN & THEN
|
||||
expect(fn () => new Email($invalid))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
})->with([
|
||||
'',
|
||||
' ',
|
||||
'not-an-email',
|
||||
'@example.com',
|
||||
'admin@',
|
||||
'admin@.com',
|
||||
'admin@com',
|
||||
]);
|
||||
|
||||
it('correctly checks equality', function (): void {
|
||||
// GIVEN
|
||||
$email1 = new Email('test@example.com');
|
||||
$email2 = new Email('TEST@EXAMPLE.COM');
|
||||
$email3 = new Email('other@example.com');
|
||||
|
||||
// THEN
|
||||
expect($email1->equals($email2))->toBeTrue()
|
||||
->and($email1->equals($email3))->toBeFalse()
|
||||
->and($email1->equals('test@example.com'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue