feat(shared): implement PSR-20 Clock contract, Utc facade, and RFC3339 normalization

This commit is contained in:
Jérémy Munsch 2026-08-21 15:45:32 +02:00
parent 1df3a27c98
commit 37bd6cdbdd
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
44 changed files with 761 additions and 145 deletions

View file

@ -9,6 +9,7 @@
"driftingly/rector-laravel": "^2.5",
"laravel/framework": "^13.24",
"laravel/sanctum": "^4.0",
"psr/clock": "^1.0",
"sentry/sentry-laravel": "^4.27",
"webmozart/assert": "^2.4"
},

2
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3a952adbc7384cb3807e6f51d0759b30",
"content-hash": "77208225d86ec68b27b8bc68f441a9fa",
"packages": [
{
"name": "brick/math",

View file

@ -10,6 +10,7 @@ use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Shared\DDD\Model\Entity;
use Shared\DDD\Model\HasTimestampIdentifier;
use Shared\Time\Utc;
/**
* @implements Entity<ClientId>
@ -17,9 +18,9 @@ use Shared\DDD\Model\HasTimestampIdentifier;
final class Client implements Entity, HasTimestampIdentifier
{
private function __construct(
public ClientId $id,
public ClientIdentifier $identifier,
public ClientName $name,
public readonly ClientId $id,
public readonly ClientIdentifier $identifier,
public readonly ClientName $name,
public ClientSecret $secret,
public bool $isActive,
public \DateTimeInterface $updatedAt,
@ -44,7 +45,7 @@ final class Client implements Entity, HasTimestampIdentifier
ClientIdentifier $identifier,
ClientSecret $secret,
): self {
$now = new \DateTimeImmutable();
$now = Utc::now();
return new self(
id: $id,
@ -77,13 +78,13 @@ final class Client implements Entity, HasTimestampIdentifier
public function regenerateSecret(ClientSecret $newSecret): void
{
$this->secret = $newSecret;
$this->updatedAt = new \DateTimeImmutable();
$this->updatedAt = Utc::now();
}
public function setActive(bool $status): bool
{
$this->isActive = $status;
$this->updatedAt = new \DateTimeImmutable();
$this->updatedAt = Utc::now();
return $this->isActive;
}

View file

@ -15,6 +15,7 @@ use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
use Shared\Time\Utc;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\CreateClient\Input, \Application\Security\Usecases\CreateClient\Output> */
@ -52,7 +53,7 @@ final readonly class CreateClient implements Transactional, Usecase
$this->dispatcher->dispatch(new ClientSecretGenerated(
$client->id,
$client->identifier,
new \DateTimeImmutable(),
Utc::now(),
));
return new Output(

View file

@ -7,6 +7,7 @@ namespace Application\Security\Usecases\GetSecurityAuditLogs;
use Application\Security\Ports\Directories\ClientAuditLogDirectory;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Shared\Time\Utc;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\GetSecurityAuditLogs\Input, \Application\Security\Usecases\GetSecurityAuditLogs\Output> */
@ -22,7 +23,7 @@ final readonly class GetSecurityAuditLogs implements Usecase
{
Assert::isInstanceOf($input, Input::class);
$to = $input->to ?? new \DateTimeImmutable();
$to = $input->to ?? Utc::now();
$entries = $this->auditLogDirectory->findLogs(
since: $input->since,

View file

@ -12,6 +12,7 @@ use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
use Shared\Time\Utc;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\RegenerateClientSecret\Input, \Application\Security\Usecases\RegenerateClientSecret\Output> */
@ -37,7 +38,7 @@ final readonly class RegenerateClientSecret implements Transactional, Usecase
$this->dispatcher->dispatch(new ClientSecretRegenerated(
$client->id,
$client->identifier,
new \DateTimeImmutable(),
Utc::now(),
));
return new Output($newSecret, $client->identifier);

View file

@ -11,6 +11,7 @@ use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Transactional;
use Shared\DDD\Usecase\Usecase;
use Shared\Time\Utc;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\ToggleClientStatus\Input, \Application\Security\Usecases\ToggleClientStatus\Output> */
@ -40,7 +41,7 @@ final readonly class SetClientStatus implements Transactional, Usecase
clientId: $client->id,
clientIdentifier: $client->identifier,
isActive: $isActive,
toggledAt: new \DateTimeImmutable(),
toggledAt: Utc::now(),
));
}

View file

@ -87,9 +87,7 @@ final class EloquentEmployeeDirectory implements EmployeeDirectory
private function toDomain(EmployeeModel $employee): Employee
{
$createdAt = $this->toNullableDateTime($employee->created_at);
if ($createdAt === null) {
if ($employee->created_at === null) {
throw new \UnexpectedValueException('Employee created_at cannot be null.');
}
@ -97,35 +95,17 @@ final class EloquentEmployeeDirectory implements EmployeeDirectory
publicEmployeeNumber: new ArcadiaEmployeeId((string) $employee->public_employee_number),
lastName: (string) $employee->last_name,
firstName: (string) $employee->first_name,
startDate: $this->toDateTime($employee->start_date),
endDate: $this->toNullableDateTime($employee->end_date),
plannedEndDate: $this->toNullableDateTime($employee->planned_end_date),
startDate: $employee->start_date,
endDate: $employee->end_date,
plannedEndDate: $employee->planned_end_date,
establishment: new EstablishmentCode((string) $employee->establishment),
site: new SiteId((string) $employee->site),
service: new ServiceId((string) $employee->service),
createdAt: $createdAt,
updatedAt: $this->toNullableDateTime($employee->updated_at),
createdAt: $employee->created_at,
updatedAt: $employee->updated_at,
);
}
private function toDateTime(\DateTimeInterface | string $value): \DateTimeInterface
{
if ($value instanceof \DateTimeInterface) {
return $value;
}
return new \DateTimeImmutable($value);
}
private function toNullableDateTime(\DateTimeInterface | string | null $value): ?\DateTimeInterface
{
if ($value === null) {
return null;
}
return $this->toDateTime($value);
}
private function toSqlColumn(EmployeeSortColumn $sortBy): string
{
return match ($sortBy) {

View file

@ -5,11 +5,13 @@ declare(strict_types=1);
namespace Infra\Adapters\Services;
use Application\Security\Ports\Services\TimestampValidator;
use Shared\Time\Clock;
final readonly class TimestampValidatorImpl implements TimestampValidator
{
public function __construct(
private \DateTimeZone $timezone
private \DateTimeZone $timezone,
private Clock $clock,
) {
}
@ -30,7 +32,7 @@ final readonly class TimestampValidatorImpl implements TimestampValidator
private function getCurrentTime(): \DateTimeImmutable
{
return new \DateTimeImmutable('now', $this->timezone);
return $this->clock->now()->setTimezone($this->timezone);
}
private function getAllowedSkew(): \DateInterval

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Infra\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Shared\Time\Utc;
/** @implements CastsAttributes<\DateTimeImmutable|null, string|\DateTimeInterface|null> */
final class UtcDateTimeCast implements CastsAttributes
{
/**
* Cast the given value from the database to DateTimeImmutable in UTC.
*
* @param array<string, mixed> $attributes
*/
#[\Override]
public function get(Model $model, string $key, mixed $value, array $attributes): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \DateTimeImmutable) {
return $value->setTimezone(new \DateTimeZone('UTC'));
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value)->setTimezone(new \DateTimeZone('UTC'));
}
if (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
return Utc::parse($trimmed);
}
return null;
}
/**
* Prepare the given value for storage in database format (Y-m-d H:i:s in UTC).
*
* @param array<string, mixed> $attributes
*/
#[\Override]
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value)
->setTimezone(new \DateTimeZone('UTC'))
->format('Y-m-d H:i:s');
}
if (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
return Utc::parse($trimmed)->format('Y-m-d H:i:s');
}
throw new \InvalidArgumentException(sprintf('Cannot cast value of type %s to UTC datetime', get_debug_type($value)));
}
}

View file

@ -12,6 +12,7 @@ use Illuminate\Notifications\Notifiable;
use Infra\Casts\ClientIdentifierCast;
use Infra\Casts\ClientNameCast;
use Infra\Casts\ClientSecretCast;
use Infra\Casts\UtcDateTimeCast;
use Infra\Casts\UuidCast;
/**
@ -21,7 +22,7 @@ use Infra\Casts\UuidCast;
* @property \Application\Security\Models\ValueObjects\ClientIdentifier $identifier ENCRYPTED, Unique identifier that should not change
* @property \Application\Security\Models\ValueObjects\ClientSecret $secret ENCRYPTED, api secret that can be regenerated
* @property bool $is_active Defines if the client can be used in order to gain access to the system
* @property \Illuminate\Support\Carbon $updated_at
* @property \DateTimeImmutable $updated_at
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
*
* @method static \Database\Factories\ApiClientFactory factory($count = null, $state = [])
@ -71,7 +72,7 @@ final class ApiClient extends Authenticatable
'identifier' => ClientIdentifierCast::class,
'secret' => ClientSecretCast::class,
'is_active' => 'boolean',
'updated_at' => 'datetime',
'updated_at' => UtcDateTimeCast::class,
];
}
}

View file

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Infra\Casts\ClientIdentifierCast;
use Infra\Casts\SecurityAuditLogDetailsCast;
use Infra\Casts\UtcDateTimeCast;
use Infra\Casts\UuidCast;
/**
@ -16,7 +17,7 @@ use Infra\Casts\UuidCast;
* @property \Application\Security\Models\ValueObjects\ClientIdentifier $client_identifier
* @property string $event_type
* @property \Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails|null $details
* @property \Illuminate\Support\Carbon $created_at
* @property \DateTimeImmutable $created_at
*
* @mixin \Eloquent
*/
@ -47,7 +48,7 @@ final class ClientAuditLog extends Model
'client_id' => UuidCast::class,
'client_identifier' => ClientIdentifierCast::class,
'details' => SecurityAuditLogDetailsCast::class,
'created_at' => 'datetime',
'created_at' => UtcDateTimeCast::class,
];
}
}

View file

@ -7,20 +7,21 @@ namespace Infra\Database\Models;
use Database\Factories\EmployeeFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Infra\Casts\UtcDateTimeCast;
use Infra\Config\ArcadiaStorageConfig;
/**
* @property string $public_employee_number
* @property string $last_name
* @property string $first_name
* @property \Illuminate\Support\Carbon $start_date
* @property \Illuminate\Support\Carbon|null $end_date
* @property \Illuminate\Support\Carbon|null $planned_end_date
* @property \DateTimeImmutable $start_date
* @property \DateTimeImmutable|null $end_date
* @property \DateTimeImmutable|null $planned_end_date
* @property string $establishment
* @property string $site
* @property string $service
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \DateTimeImmutable|null $created_at
* @property \DateTimeImmutable|null $updated_at
*
* @method static \Database\Factories\EmployeeFactory factory($count = null, $state = [])
*
@ -71,11 +72,11 @@ final class Employee extends Model
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
'planned_end_date' => 'date',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'start_date' => UtcDateTimeCast::class,
'end_date' => UtcDateTimeCast::class,
'planned_end_date' => UtcDateTimeCast::class,
'created_at' => UtcDateTimeCast::class,
'updated_at' => UtcDateTimeCast::class,
];
}
}

View file

@ -30,6 +30,8 @@ class AppServiceProvider extends ServiceProvider
public function boot(AppConfig $appConfig, ThrottlingConfig $throttlingConfig): void
{
date_default_timezone_set($appConfig->timezone->name);
$this->configureRateLimiting($throttlingConfig);
ini_set('assert.active', '1');

View file

@ -13,10 +13,14 @@ 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\Foundation\Application;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Infra\Config\ArcadiaStorageConfig;
use Psr\Clock\ClockInterface;
use Ramsey\Uuid\Uuid;
use Shared\Time\Clock;
use Shared\Time\SystemClock;
use Shared\ValueObjects\Config\AppName;
use Shared\ValueObjects\Config\AppTimezone;
use Shared\ValueObjects\Config\Locale;
@ -28,6 +32,18 @@ final class ConfigServiceProvider extends ServiceProvider
#[\Override]
public function register(): void
{
$this->app->singleton(ClockInterface::class, static function (Application $app): Clock {
$appConfig = $app->make(AppConfig::class);
return new SystemClock($appConfig->timezone->toDateTimeZone());
});
$this->app->singleton(Clock::class, static function (Application $app): Clock {
$appConfig = $app->make(AppConfig::class);
return new SystemClock($appConfig->timezone->toDateTimeZone());
});
$this->app->bind(SecurityAlertMailConfig::class, static function (): SecurityAlertMailConfig {
return new SecurityAlertMailConfig(
recipientEmail: new Email(Config::string('mail.security_recipient', 'security@example.com')),

View file

@ -26,6 +26,7 @@ use Presentation\Api\V1\Security\ApiUserDetailsProvider;
use Presentation\Console\Security\ConsoleUserDetailsProvider;
use Psr\Log\LoggerInterface;
use Shared\DDD\Security\UserDetailsProvider;
use Shared\Time\Clock;
final class SecurityServiceProvider extends ServiceProvider
{
@ -49,8 +50,9 @@ final class SecurityServiceProvider extends ServiceProvider
$this->app->bind(TimestampValidator::class, function (Application $app): TimestampValidatorImpl {
$appConfig = $app->make(AppConfig::class);
$clock = $app->make(Clock::class);
return new TimestampValidatorImpl($appConfig->timezone->toDateTimeZone());
return new TimestampValidatorImpl($appConfig->timezone->toDateTimeZone(), $clock);
});
$this->app->bind(RequestValidator::class, RequestValidatorImpl::class);

View file

@ -7,6 +7,7 @@ namespace Presentation\Api\V1\Dto\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Presentation\Api\V1\Contracts\ApiResponseDto;
use Presentation\Api\V1\Presenters\Brands\BrandShowView;
use Shared\Time\Utc;
final readonly class BrandDto implements ApiResponseDto
{
@ -26,7 +27,7 @@ final readonly class BrandDto implements ApiResponseDto
code: $view->code->value,
name: $view->name->value,
arcadiaId: $view->arcadiaId->value,
createdAt: $view->createdAt->format(\DateTimeInterface::ATOM),
createdAt: Utc::toRfc3339($view->createdAt),
);
}
@ -37,7 +38,7 @@ final readonly class BrandDto implements ApiResponseDto
code: $item->code->value,
name: $item->name->value,
arcadiaId: $item->arcadiaId->value,
createdAt: $item->createdAt->format(\DateTimeInterface::ATOM),
createdAt: Utc::toRfc3339($item->createdAt),
);
}

View file

@ -12,8 +12,8 @@ use Domain\Models\ValueObjects\ServiceId;
use Domain\Models\ValueObjects\SiteId;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Shared\Helpers\DatesHelper;
use Shared\Pagination;
use Shared\Time\Utc;
/**
* @method \Shared\Pagination pagination(int $defaultPageSize = 25)
@ -49,7 +49,7 @@ final class EmployeesIndexRequest extends FormRequest
public function filter(): ?EmployeeFilter
{
$since = $this->filled('since') ? DatesHelper::parseDateTime($this->string('since')->value()) : null;
$since = $this->filled('since') ? Utc::parse($this->string('since')->value()) : null;
$establishment = $this->filled('establishment') ? new EstablishmentCode($this->string('establishment')->value()) : null;
$service = $this->filled('serviceId') ? new ServiceId($this->string('serviceId')->value()) : null;

View file

@ -10,8 +10,8 @@ use Domain\Models\ValueObjects\Inco\IncoSheetSortColumn;
use Domain\Models\ValueObjects\Inco\IncoSheetSorter;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Shared\Helpers\DatesHelper;
use Shared\Pagination;
use Shared\Time\Utc;
/**
* @method \Shared\Pagination pagination(int $defaultPageSize = 25)
@ -46,7 +46,7 @@ final class IncoSheetsIndexRequest extends FormRequest
public function filter(): ?IncoSheetFilter
{
$since = $this->filled('since') ? DatesHelper::parseDateTime($this->string('since')->value()) : null;
$since = $this->filled('since') ? Utc::parse($this->string('since')->value()) : null;
$brandCodes = [];
$rawBrands = $this->array('brands');

View file

@ -22,6 +22,7 @@ use Presentation\Api\V1\Dto\IncoSheets\Parts\StorageConditionDto;
use Presentation\Api\V1\Dto\IncoSheets\Parts\WeightDto;
use Presentation\Api\V1\Dto\PaginationDto;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsIndexView;
use Shared\Time\Utc;
use Uri\Rfc3986\Uri;
final readonly class IncoSheetsIndexResponse implements ApiResponse
@ -46,7 +47,7 @@ final readonly class IncoSheetsIndexResponse implements ApiResponse
pageSize: $this->view->perPage,
totalItems: $this->view->total,
baseUri: $this->baseUri,
);
)->toArray();
$items = array_map(
fn (PaginatedIncoSheet | PaginatedShortIncoSheet $sheet): array => match (true) {
@ -68,8 +69,8 @@ final readonly class IncoSheetsIndexResponse implements ApiResponse
id: (string) $sheet->id,
name: $sheet->name,
label: $sheet->label,
createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM),
updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM),
createdAt: Utc::toNullableRfc3339($sheet->createdAt),
updatedAt: Utc::toNullableRfc3339($sheet->updatedAt),
);
}
@ -104,8 +105,8 @@ final readonly class IncoSheetsIndexResponse implements ApiResponse
$sheet->additives
)),
compo: $sheet->compo !== null ? (string) $sheet->compo : null,
createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM),
updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM),
createdAt: Utc::toNullableRfc3339($sheet->createdAt),
updatedAt: Utc::toNullableRfc3339($sheet->updatedAt),
);
}
}

View file

@ -22,6 +22,7 @@ use Presentation\Api\V1\Dto\IncoSheets\Parts\StorageConditionDto;
use Presentation\Api\V1\Dto\IncoSheets\Parts\WeightDto;
use Presentation\Api\V1\Dto\PaginationDto;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsSearchView;
use Shared\Time\Utc;
use Uri\Rfc3986\Uri;
final readonly class IncoSheetsSearchResponse implements ApiResponse
@ -46,7 +47,7 @@ final readonly class IncoSheetsSearchResponse implements ApiResponse
pageSize: $this->view->perPage,
totalItems: $this->view->total,
baseUri: $this->baseUri,
);
)->toArray();
$items = array_map(
fn (PaginatedIncoSheet | PaginatedShortIncoSheet $sheet): array => match (true) {
@ -68,8 +69,8 @@ final readonly class IncoSheetsSearchResponse implements ApiResponse
id: (string) $sheet->id,
name: $sheet->name,
label: $sheet->label,
createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM),
updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM),
createdAt: Utc::toNullableRfc3339($sheet->createdAt),
updatedAt: Utc::toNullableRfc3339($sheet->updatedAt),
);
}
@ -104,8 +105,8 @@ final readonly class IncoSheetsSearchResponse implements ApiResponse
$sheet->additives
)),
compo: $sheet->compo !== null ? (string) $sheet->compo : null,
createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM),
updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM),
createdAt: Utc::toNullableRfc3339($sheet->createdAt),
updatedAt: Utc::toNullableRfc3339($sheet->updatedAt),
);
}
}

View file

@ -19,6 +19,7 @@ use Presentation\Api\V1\Dto\IncoSheets\Parts\PortionsDto;
use Presentation\Api\V1\Dto\IncoSheets\Parts\StorageConditionDto;
use Presentation\Api\V1\Dto\IncoSheets\Parts\WeightDto;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsShowView;
use Shared\Time\Utc;
final readonly class IncoSheetsShowResponse implements ApiResponse
{
@ -72,8 +73,8 @@ final readonly class IncoSheetsShowResponse implements ApiResponse
$output->additives
)),
compo: $output->compo !== null ? (string) $output->compo : null,
createdAt: $output->createdAt?->format(\DateTimeInterface::ATOM),
updatedAt: $output->updatedAt?->format(\DateTimeInterface::ATOM),
createdAt: Utc::toNullableRfc3339($output->createdAt),
updatedAt: Utc::toNullableRfc3339($output->updatedAt),
);
}
}

View file

@ -13,6 +13,7 @@ use Presentation\Console\Presenters\Security\AuditLogsPresenter;
use Presentation\Console\Presenters\Security\AuditLogsView;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
use Shared\Time\Utc;
final class ExportSecurityAuditLogsCommand extends Command
{
@ -23,28 +24,33 @@ final class ExportSecurityAuditLogsCommand extends Command
{--since= : Retrieve events occurred since this date (e.g. "2026-08-01", "-7 days")}
{--to= : Retrieve events occurred up to this date (defaults to now)}
{--client= : Filter by client ID or identifier}
{--event= : Filter by event type (e.g. "client_secret_generated", "client_secret_regenerated", "client_status_toggled")}
{--csv : Export output to CSV file in storage/app/audit/}';
{--event= : Filter by event type}
{--csv : Export output as CSV file in storage/app/audit/}';
/**
* @var string
*/
protected $description = 'Display or export security audit events since a specified date';
public function handle(
GetSecurityAuditLogs $usecase,
UsecaseExecutor $executor,
AuditLogsPresenter $presenter,
): int {
public function __construct(
private GetSecurityAuditLogs $usecase,
private UsecaseExecutor $executor,
private AuditLogsPresenter $presenter,
) {
parent::__construct();
}
public function handle(): int
{
$input = $this->buildInput();
if ($input === null) {
return self::FAILURE;
}
$view = UsecaseBuilder::using($executor)
->do($usecase)
$view = UsecaseBuilder::using($this->executor)
->do($this->usecase)
->from($input)
->with($presenter)
->with($this->presenter)
->run();
if ($this->option('csv')) {
@ -62,7 +68,7 @@ final class ExportSecurityAuditLogsCommand extends Command
: '-7 days';
try {
$since = new \DateTimeImmutable($sinceString);
$since = Utc::parse($sinceString);
} catch (\Exception) {
$this->error("Invalid date format for --since: '{$sinceString}'.");
@ -73,7 +79,7 @@ final class ExportSecurityAuditLogsCommand extends Command
$to = null;
if (is_string($toOption) && trim($toOption) !== '') {
try {
$to = new \DateTimeImmutable(trim($toOption));
$to = Utc::parse(trim($toOption));
} catch (\Exception) {
$this->error("Invalid date format for --to: '{$toOption}'.");
@ -127,7 +133,7 @@ final class ExportSecurityAuditLogsCommand extends Command
File::makeDirectory($dir, 0755, true);
}
$filename = sprintf('security-audit-%s.csv', (new \DateTimeImmutable())->format('Y-m-d-His'));
$filename = sprintf('security-audit-%s.csv', Utc::now()->format('Y-m-d-His'));
$filePath = "{$dir}/{$filename}";
File::put($filePath, $view->toCsvContent());

View file

@ -8,6 +8,7 @@ use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Application\Arcadia\Usecase\Brands\FindAll\Output;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Shared\Time\Utc;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, ListBrandsView> */
@ -25,7 +26,7 @@ final readonly class ListBrandsPresenter implements Presenter
code: $brand->code->value,
name: $brand->name->value,
arcadiaId: $brand->arcadiaId->value,
createdAt: $brand->createdAt->format('Y-m-d H:i:s'),
createdAt: Utc::toRfc3339($brand->createdAt),
),
$output->brands
));

View file

@ -8,6 +8,7 @@ use Application\Security\Usecases\FindAllClients\Output;
use Application\Security\Usecases\FindAllClients\PaginatedClient;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Shared\Time\Utc;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, ListClientsView> */
@ -25,7 +26,7 @@ final readonly class ListClientsPresenter implements Presenter
name: $client->name->value,
identifier: $client->identifier->value,
status: $client->isActive ? 'Active' : 'Inactive',
lastUpdatedAt: $client->updatedAt->format('Y-m-d H:i:s'),
lastUpdatedAt: Utc::toRfc3339($client->updatedAt),
),
$output->clients
));

View file

@ -10,13 +10,13 @@ use Shared\Helpers\Exceptions\InvalidDateFormatException;
final class DatesHelper
{
private const string FRENCH_DATE_REGEX = '/^\d{4}-\d{2}-\d{2}$/';
private const string FRENCH_DATE_REGEX = '/^\d{4}-\d{2}-\d{2}$/';
private const string FRENCH_DATE_INVERT_REGEX = '/^\d{2}-\d{2}-\d{4}$/';
private const string FRENCH_DATE_INVERT_REGEX = '/^\d{2}-\d{2}-\d{4}$/';
private const string ISO8601_REGEX = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/';
private const string ISO8601_REGEX = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/';
private const string RFC3339_REGEX = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})$/';
private const string RFC3339_REGEX = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})$/';
private function __construct()
{
@ -24,11 +24,11 @@ final class DatesHelper
}
/**
* Vérifie si la date actuelle se situe dans l'intervalle donné.
* Vérifie si la date actuelle se situe dans l'intervalle donné (en UTC).
*/
public static function isIn(?CarbonInterface $from, ?CarbonInterface $to): bool
{
$now = CarbonImmutable::now();
$now = CarbonImmutable::now(new \DateTimeZone('UTC'));
return match (true) {
$from !== null && $to !== null => $now->between($from, $to),
@ -40,9 +40,20 @@ final class DatesHelper
public static function toRfc3339(\DateTimeInterface | string $datetime): string
{
$datetime = is_string($datetime) ? self::parseDateTime($datetime) : $datetime;
$dt = is_string($datetime) ? self::parseDateTime($datetime) : $datetime;
return $datetime->format(\DATE_RFC3339);
if ($dt instanceof \DateTimeImmutable) {
$dt = $dt->setTimezone(new \DateTimeZone('UTC'));
} elseif ($dt instanceof \DateTime) {
$dt = (clone $dt)->setTimezone(new \DateTimeZone('UTC'));
}
return $dt->format(\DATE_RFC3339);
}
public static function toNullableRfc3339(\DateTimeInterface | string | null $datetime): ?string
{
return $datetime !== null ? self::toRfc3339($datetime) : null;
}
public static function parseDateTime(string $datetime): \DateTimeInterface
@ -67,7 +78,7 @@ final class DatesHelper
break;
default:
try {
$output = new \DateTimeImmutable($datetime);
$output = (new \DateTimeImmutable($datetime, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('UTC'));
} catch (\Exception) {
throw InvalidDateFormatException::forDateString($datetime);
}
@ -78,41 +89,41 @@ final class DatesHelper
protected static function parseFrench(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $datetime . ' 00:00:00');
$dt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $datetime . ' 00:00:00', new \DateTimeZone('UTC'));
return $dt ?: self::parseFallback($datetime);
}
protected static function parseFrenchInverted(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat('d-m-Y H:i:s', $datetime . ' 00:00:00');
$dt = \DateTimeImmutable::createFromFormat('d-m-Y H:i:s', $datetime . ' 00:00:00', new \DateTimeZone('UTC'));
return $dt ?: self::parseFallback($datetime);
}
protected static function parseRfc3339(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat(\DateTime::RFC3339, $datetime);
$dt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::RFC3339, $datetime);
if ($dt !== false) {
return $dt;
return $dt->setTimezone(new \DateTimeZone('UTC'));
}
$dt = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uP', $datetime);
return $dt ?: self::parseFallback($datetime);
return $dt ? $dt->setTimezone(new \DateTimeZone('UTC')) : self::parseFallback($datetime);
}
protected static function parseIso8601(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $datetime);
return $dt ?: self::parseFallback($datetime);
return $dt ? $dt->setTimezone(new \DateTimeZone('UTC')) : self::parseFallback($datetime);
}
protected static function parseFallback(string $datetime): \DateTimeInterface
{
try {
return new \DateTimeImmutable($datetime);
return (new \DateTimeImmutable($datetime, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('UTC'));
} catch (\Throwable) {
throw InvalidDateFormatException::forDateString($datetime);
}

View file

@ -79,6 +79,6 @@ final readonly class UuidHelper
throw new InvalidUuidException("Failed to extract datetime from UUID: {$uuid->toString()}");
}
return $dateTime;
return $dateTime->setTimezone(new \DateTimeZone('UTC'));
}
}

25
src/Shared/Time/Clock.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Shared\Time;
use Psr\Clock\ClockInterface;
interface Clock extends ClockInterface
{
#[\Override]
public function now(): \DateTimeImmutable;
public function parse(string $datetime): \DateTimeImmutable;
public function startOfDay(\DateTimeInterface | string $date): \DateTimeImmutable;
public function endOfDay(\DateTimeInterface | string $date): \DateTimeImmutable;
public function toIso8601(\DateTimeInterface | string $date): string;
public function toRfc3339(\DateTimeInterface | string $date): string;
public function toNullableRfc3339(\DateTimeInterface | string | null $date): ?string;
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Shared\Time;
use Shared\Helpers\DatesHelper;
final class FrozenClock implements Clock
{
private const string UTC = 'UTC';
private \DateTimeImmutable $now;
public function __construct(?\DateTimeInterface $now = null)
{
if ($now === null) {
$this->now = new \DateTimeImmutable('now', new \DateTimeZone(self::UTC));
} else {
$this->now = \DateTimeImmutable::createFromInterface($now)->setTimezone(new \DateTimeZone(self::UTC));
}
}
public function setTo(\DateTimeInterface | string $time): void
{
if (is_string($time)) {
$this->now = $this->parse($time);
} else {
$this->now = \DateTimeImmutable::createFromInterface($time)->setTimezone(new \DateTimeZone(self::UTC));
}
}
#[\Override]
public function now(): \DateTimeImmutable
{
return $this->now;
}
#[\Override]
public function parse(string $datetime): \DateTimeImmutable
{
$parsed = DatesHelper::parseDateTime($datetime);
if ($parsed instanceof \DateTimeImmutable) {
return $parsed->setTimezone(new \DateTimeZone(self::UTC));
}
return \DateTimeImmutable::createFromInterface($parsed)->setTimezone(new \DateTimeZone(self::UTC));
}
#[\Override]
public function startOfDay(\DateTimeInterface | string $date): \DateTimeImmutable
{
$dt = is_string($date) ? $this->parse($date) : $date;
$dt = \DateTimeImmutable::createFromInterface($dt)->setTimezone(new \DateTimeZone(self::UTC));
return $dt->setTime(0, 0, 0, 0);
}
#[\Override]
public function endOfDay(\DateTimeInterface | string $date): \DateTimeImmutable
{
$dt = is_string($date) ? $this->parse($date) : $date;
$dt = \DateTimeImmutable::createFromInterface($dt)->setTimezone(new \DateTimeZone(self::UTC));
return $dt->setTime(23, 59, 59, 999999);
}
#[\Override]
public function toIso8601(\DateTimeInterface | string $date): string
{
return $this->toRfc3339($date);
}
#[\Override]
public function toRfc3339(\DateTimeInterface | string $date): string
{
return DatesHelper::toRfc3339($date);
}
#[\Override]
public function toNullableRfc3339(\DateTimeInterface | string | null $date): ?string
{
return DatesHelper::toNullableRfc3339($date);
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Shared\Time;
use Shared\Helpers\DatesHelper;
readonly class SystemClock implements Clock
{
private \DateTimeZone $timezone;
public function __construct(\DateTimeZone | string $timezone = 'UTC')
{
$this->timezone = is_string($timezone) ? new \DateTimeZone($timezone) : $timezone;
}
public function timezone(): \DateTimeZone
{
return $this->timezone;
}
#[\Override]
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable('now', $this->timezone);
}
#[\Override]
public function parse(string $datetime): \DateTimeImmutable
{
$parsed = DatesHelper::parseDateTime($datetime);
if ($parsed instanceof \DateTimeImmutable) {
return $parsed->setTimezone($this->timezone);
}
return \DateTimeImmutable::createFromInterface($parsed)->setTimezone($this->timezone);
}
#[\Override]
public function startOfDay(\DateTimeInterface | string $date): \DateTimeImmutable
{
$dt = is_string($date) ? $this->parse($date) : $date;
$dt = \DateTimeImmutable::createFromInterface($dt)->setTimezone($this->timezone);
return $dt->setTime(0, 0, 0, 0);
}
#[\Override]
public function endOfDay(\DateTimeInterface | string $date): \DateTimeImmutable
{
$dt = is_string($date) ? $this->parse($date) : $date;
$dt = \DateTimeImmutable::createFromInterface($dt)->setTimezone($this->timezone);
return $dt->setTime(23, 59, 59, 999999);
}
#[\Override]
public function toIso8601(\DateTimeInterface | string $date): string
{
return $this->toRfc3339($date);
}
#[\Override]
public function toRfc3339(\DateTimeInterface | string $date): string
{
return DatesHelper::toRfc3339($date);
}
#[\Override]
public function toNullableRfc3339(\DateTimeInterface | string | null $date): ?string
{
return DatesHelper::toNullableRfc3339($date);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shared\Time;
final readonly class SystemUtcClock extends SystemClock
{
public function __construct()
{
parent::__construct(new \DateTimeZone('UTC'));
}
}

73
src/Shared/Time/Utc.php Normal file
View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Shared\Time;
final class Utc
{
private static ?Clock $clock = null;
private function __construct()
{
// Static utility class
}
public static function getClock(): Clock
{
return self::$clock ??= new SystemUtcClock();
}
public static function setClock(?Clock $clock): void
{
self::$clock = $clock;
}
public static function freeze(?\DateTimeInterface $at = null): FrozenClock
{
$frozen = new FrozenClock($at);
self::$clock = $frozen;
return $frozen;
}
public static function restore(): void
{
self::$clock = null;
}
public static function now(): \DateTimeImmutable
{
return self::getClock()->now();
}
public static function parse(string $datetime): \DateTimeImmutable
{
return self::getClock()->parse($datetime);
}
public static function startOfDay(\DateTimeInterface | string $date): \DateTimeImmutable
{
return self::getClock()->startOfDay($date);
}
public static function endOfDay(\DateTimeInterface | string $date): \DateTimeImmutable
{
return self::getClock()->endOfDay($date);
}
public static function toIso8601(\DateTimeInterface | string $date): string
{
return self::getClock()->toIso8601($date);
}
public static function toRfc3339(\DateTimeInterface | string $date): string
{
return self::getClock()->toRfc3339($date);
}
public static function toNullableRfc3339(\DateTimeInterface | string | null $date): ?string
{
return self::getClock()->toNullableRfc3339($date);
}
}

View file

@ -66,6 +66,10 @@ describe('Coding Standards & Project Rules', function (): void {
->expect(['sleep', 'usleep'])
->not->toBeUsed();
arch('mutable DateTime is not used in src (use DateTimeImmutable or DateTimeInterface)')
->expect('DateTime')
->not->toBeUsed();
arch('all Domain source files use strict types')
->expect('Domain')
->toUseStrictTypes();

View file

@ -7,6 +7,7 @@ namespace Tests\Feature\Console\Commands;
use Infra\Database\Models\ApiClient;
use Presentation\Console\Commands\ListClientsCommand;
use Ramsey\Uuid\Uuid;
use Shared\Time\Utc;
covers(ListClientsCommand::class);
@ -57,14 +58,14 @@ describe('ListClientsCommand', function (): void {
(string) $client1->name->value,
(string) $client1->identifier->value,
'Active',
$client1->updated_at->format('Y-m-d H:i:s'),
Utc::toRfc3339($client1->updated_at),
],
[
(string) $client2->id,
(string) $client2->name->value,
(string) $client2->identifier->value,
'Inactive',
$client2->updated_at->format('Y-m-d H:i:s'),
Utc::toRfc3339($client2->updated_at),
],
]
)

View file

@ -65,20 +65,6 @@ describe('EloquentEmployeeDirectory Integration Tests', function (): void {
->and($result?->updatedAt?->format('Y-m-d H:i:s'))->toBe('2024-01-02 12:00:00');
});
it('converts string dates to DateTimeInterface using toDateTime helper', function (): void {
// GIVEN
$directory = new EloquentEmployeeDirectory();
$reflection = new \ReflectionMethod(EloquentEmployeeDirectory::class, 'toDateTime');
// WHEN
/** @var \DateTimeInterface $result */
$result = $reflection->invoke($directory, '2024-01-15 14:30:00');
// THEN
expect($result)->toBeInstanceOf(\DateTimeInterface::class)
->and($result->format('Y-m-d H:i:s'))->toBe('2024-01-15 14:30:00');
});
it('returns null when employee is not found', function (): void {
// GIVEN
$directory = new EloquentEmployeeDirectory();

View file

@ -10,6 +10,9 @@ use Application\Security\Config\HmacAuthConfig;
use Application\Security\Config\SecurityAlertMailConfig;
use Infra\Config\ArcadiaStorageConfig;
use Infra\Providers\ConfigServiceProvider;
use Psr\Clock\ClockInterface;
use Shared\Time\Clock;
use Shared\Time\SystemClock;
use Tests\TestCase;
uses(TestCase::class);
@ -66,4 +69,15 @@ describe('ConfigServiceProvider Integration Tests', function (): void {
->and($config->cacheStore)->not->toBeEmpty()
->and($config->databaseConnection)->not->toBeEmpty();
});
it('resolves PSR-20 ClockInterface and Clock service singleton configured with AppConfig timezone', function (): void {
// WHEN
$clock = resolve(Clock::class);
$clockInterface = resolve(ClockInterface::class);
// THEN
expect($clock)->toBeInstanceOf(SystemClock::class)
->and($clockInterface)->toBeInstanceOf(SystemClock::class)
->and($clock->now()->getTimezone()->getName())->toBe('UTC');
});
});

View file

@ -28,13 +28,14 @@ describe('Date Helper Integration Tests', function (): void {
->toThrow(\ReflectionException::class);
});
it('can parse various date formats', function (string $input, string $expectedFormat): void {
it('can parse various date formats and normalizes to UTC', function (string $input, string $expectedFormat): void {
// GIVEN & WHEN
$result = DatesHelper::parseDateTime($input);
// THEN
expect($result)->toBeInstanceOf(\DateTimeInterface::class)
->and($result->format('Y-m-d'))->toBe($expectedFormat);
->and($result->format('Y-m-d'))->toBe($expectedFormat)
->and($result->getTimezone()->getName())->toBe('UTC');
})->with([
['2025-12-25 12:10:23', '2025-12-25'],
['2025-12-25', '2025-12-25'],
@ -45,6 +46,18 @@ describe('Date Helper Integration Tests', function (): void {
['2025/12/25', '2025-12-25'],
]);
it('converts non-UTC input timezone to exact UTC instant', function (): void {
// GIVEN
$inputWithOffset = '2025-12-25T14:10:23+02:00';
// WHEN
$result = DatesHelper::parseDateTime($inputWithOffset);
// THEN
expect($result->getTimezone()->getName())->toBe('UTC')
->and($result->format('Y-m-d H:i:s'))->toBe('2025-12-25 12:10:23');
});
it('throws InvalidDateFormatException for unparseable date strings', function (): void {
// GIVEN
$invalidDate = 'not-a-real-date-string';
@ -63,7 +76,8 @@ describe('Date Helper Integration Tests', function (): void {
// THEN
expect($result)->toBeInstanceOf(\DateTimeInterface::class)
->and($result->format('Y-m-d'))->toBe('2025-12-25');
->and($result->format('Y-m-d'))->toBe('2025-12-25')
->and($result->getTimezone()->getName())->toBe('UTC');
});
it('throws InvalidDateFormatException in parseFallback when fallback date is completely invalid logically', function (): void {
@ -75,14 +89,14 @@ describe('Date Helper Integration Tests', function (): void {
->toThrow(InvalidDateFormatException::class, "Unrecognized date/time format: {$invalidFallbackDate}");
});
it('handles isIn date boundary checks correctly', function (): void {
it('handles isIn date boundary checks correctly in UTC', function (): void {
// GIVEN
$now = CarbonImmutable::create(2025, 6, 15, 12, 0, 0);
$now = CarbonImmutable::create(2025, 6, 15, 12, 0, 0, 'UTC');
CarbonImmutable::setTestNow($now);
$past = CarbonImmutable::create(2025, 1, 1);
$future = CarbonImmutable::create(2025, 12, 31);
$farFuture = CarbonImmutable::create(2025, 12, 30);
$past = CarbonImmutable::create(2025, 1, 1, 0, 0, 0, 'UTC');
$future = CarbonImmutable::create(2025, 12, 31, 0, 0, 0, 'UTC');
$farFuture = CarbonImmutable::create(2025, 12, 30, 0, 0, 0, 'UTC');
// WHEN & THEN
expect(DatesHelper::isIn($past, $future))->toBeTrue()
@ -94,16 +108,18 @@ describe('Date Helper Integration Tests', function (): void {
->and(DatesHelper::isIn(null, null))->toBeFalse();
});
it('formats string or DateTimeInterface to RFC3339', function (): void {
it('formats string or DateTimeInterface to RFC3339 in UTC', function (): void {
// GIVEN
$dateTimeObj = new \DateTimeImmutable('2025-12-25 12:10:23');
$dateTimeObj = new \DateTimeImmutable('2025-12-25 12:10:23', new \DateTimeZone('Europe/Paris'));
// WHEN
$resultFromObj = DatesHelper::toRfc3339($dateTimeObj);
$resultFromStr = DatesHelper::toRfc3339('2025-12-25 12:10:23');
// THEN
expect($resultFromObj)->toContain('2025-12-25T12:10:23')
->and($resultFromStr)->toContain('2025-12-25T12:10:23');
expect($resultFromObj)->toBe('2025-12-25T11:10:23+00:00')
->and($resultFromStr)->toBe('2025-12-25T12:10:23+00:00')
->and(DatesHelper::toNullableRfc3339(null))->toBeNull()
->and(DatesHelper::toNullableRfc3339($dateTimeObj))->toBe('2025-12-25T11:10:23+00:00');
});
});

View file

@ -4,18 +4,19 @@ declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Services;
use DateTimeImmutable;
use DateTimeZone;
use Infra\Adapters\Services\TimestampValidatorImpl;
use Shared\Time\FrozenClock;
covers(TimestampValidatorImpl::class);
describe('TimestampValidatorImpl Unit Tests', function (): void {
it('accepts timestamps within the 300 seconds skew window', function (string $modifier): void {
// GIVEN
$timezone = new DateTimeZone('UTC');
$validator = new TimestampValidatorImpl($timezone);
$timestamp = new DateTimeImmutable($modifier, $timezone);
$timezone = new \DateTimeZone('UTC');
$fixedTime = new \DateTimeImmutable('2026-08-21 12:00:00', $timezone);
$frozenClock = new FrozenClock($fixedTime);
$validator = new TimestampValidatorImpl($timezone, $frozenClock);
$timestamp = $fixedTime->modify($modifier);
// WHEN
$isValid = $validator->validate($timestamp);
@ -23,7 +24,7 @@ describe('TimestampValidatorImpl Unit Tests', function (): void {
// THEN
expect($isValid)->toBeTrue();
})->with([
'now',
'0 seconds',
'-2 minutes',
'+2 minutes',
'-299 seconds',
@ -32,9 +33,11 @@ describe('TimestampValidatorImpl Unit Tests', function (): void {
it('rejects timestamps outside the 300 seconds skew window', function (string $modifier): void {
// GIVEN
$timezone = new DateTimeZone('UTC');
$validator = new TimestampValidatorImpl($timezone);
$timestamp = new DateTimeImmutable($modifier, $timezone);
$timezone = new \DateTimeZone('UTC');
$fixedTime = new \DateTimeImmutable('2026-08-21 12:00:00', $timezone);
$frozenClock = new FrozenClock($fixedTime);
$validator = new TimestampValidatorImpl($timezone, $frozenClock);
$timestamp = $fixedTime->modify($modifier);
// WHEN
$isValid = $validator->validate($timestamp);

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Casts;
use Illuminate\Database\Eloquent\Model;
use Infra\Casts\UtcDateTimeCast;
covers(UtcDateTimeCast::class);
describe('UtcDateTimeCast Unit Tests', function (): void {
it('casts database string to UTC DateTimeImmutable', function (): void {
// GIVEN
$cast = new UtcDateTimeCast();
$model = new class () extends Model
{
};
// WHEN
$result = $cast->get($model, 'created_at', '2026-08-21 14:30:00', []);
// THEN
expect($result)->toBeInstanceOf(\DateTimeImmutable::class)
->and($result?->getTimezone()->getName())->toBe('UTC')
->and($result?->format('Y-m-d H:i:s'))->toBe('2026-08-21 14:30:00');
});
it('casts non-UTC DateTimeInterface to UTC DateTimeImmutable upon get', function (): void {
// GIVEN
$cast = new UtcDateTimeCast();
$model = new class () extends Model
{
};
$parisDate = new \DateTimeImmutable('2026-08-21 16:30:00', new \DateTimeZone('Europe/Paris'));
// WHEN
$result = $cast->get($model, 'created_at', $parisDate, []);
// THEN
expect($result)->toBeInstanceOf(\DateTimeImmutable::class)
->and($result?->getTimezone()->getName())->toBe('UTC')
->and($result?->format('Y-m-d H:i:s'))->toBe('2026-08-21 14:30:00');
});
it('handles null and empty string upon get', function (): void {
// GIVEN
$cast = new UtcDateTimeCast();
$model = new class () extends Model
{
};
// WHEN & THEN
expect($cast->get($model, 'created_at', null, []))->toBeNull()
->and($cast->get($model, 'created_at', '', []))->toBeNull()
->and($cast->get($model, 'created_at', ' ', []))->toBeNull();
});
it('sets DateTimeInterface to UTC string format for database storage', function (): void {
// GIVEN
$cast = new UtcDateTimeCast();
$model = new class () extends Model
{
};
$parisDate = new \DateTimeImmutable('2026-08-21 16:30:00', new \DateTimeZone('Europe/Paris'));
// WHEN
$result = $cast->set($model, 'created_at', $parisDate, []);
// THEN
expect($result)->toBe('2026-08-21 14:30:00');
});
it('sets string date to UTC string format for database storage', function (): void {
// GIVEN
$cast = new UtcDateTimeCast();
$model = new class () extends Model
{
};
// WHEN
$result = $cast->set($model, 'created_at', '2026-08-21T16:30:00+02:00', []);
// THEN
expect($result)->toBe('2026-08-21 14:30:00');
});
it('handles null and empty string upon set', function (): void {
// GIVEN
$cast = new UtcDateTimeCast();
$model = new class () extends Model
{
};
// WHEN & THEN
expect($cast->set($model, 'created_at', null, []))->toBeNull()
->and($cast->set($model, 'created_at', '', []))->toBeNull()
->and($cast->set($model, 'created_at', ' ', []))->toBeNull();
});
it('throws InvalidArgumentException when setting unsupported type', function (): void {
// GIVEN
$cast = new UtcDateTimeCast();
$model = new class () extends Model
{
};
// WHEN & THEN
expect(fn () => $cast->set($model, 'created_at', 12345, []))
->toThrow(\InvalidArgumentException::class, 'Cannot cast value of type int to UTC datetime');
});
});

View file

@ -12,6 +12,7 @@ use Domain\Models\ValueObjects\Brand\BrandName;
use Presentation\Api\V1\Dto\Brands\BrandDto;
use Presentation\Api\V1\Presenters\Brands\BrandShowView;
use Ramsey\Uuid\Uuid;
use Shared\Helpers\DatesHelper;
covers(BrandDto::class);
@ -40,13 +41,13 @@ describe('BrandDto Unit Tests', function (): void {
->and($dto->name)->toBe('Le Gaulois')
->and($dto->code)->toBe('LGL01')
->and($dto->arcadiaId)->toBe(1)
->and($dto->createdAt)->toBe($createdAt->format(\DateTimeInterface::ATOM))
->and($dto->createdAt)->toBe(DatesHelper::toRfc3339($createdAt))
->and($dto->toArray())->toBe([
'id' => (string) $id,
'code' => 'LGL01',
'name' => 'Le Gaulois',
'arcadiaId' => 1,
'createdAt' => $createdAt->format(\DateTimeInterface::ATOM),
'createdAt' => DatesHelper::toRfc3339($createdAt),
]);
});

View file

@ -50,7 +50,7 @@ describe('ListBrandsPresenter Unit Tests', function (): void {
->and($view->rows[0]->code)->toBe('LGL01')
->and($view->rows[0]->name)->toBe('Le Gaulois')
->and($view->rows[0]->arcadiaId)->toBe(1)
->and($view->rows[0]->createdAt)->toBe('2024-01-15 08:00:00');
->and($view->rows[0]->createdAt)->toBe('2024-01-15T08:00:00+00:00');
});
it('returns empty view when no brands in output', function (): void {

View file

@ -60,7 +60,7 @@ describe('ListClientsPresenter Unit Tests', function (): void {
->and($view->rows[0]->name)->toBe('Client 1')
->and($view->rows[0]->identifier)->toBe($identifier->value)
->and($view->rows[0]->status)->toBe('Active')
->and($view->rows[0]->lastUpdatedAt)->toBe('2024-01-15 10:30:00');
->and($view->rows[0]->lastUpdatedAt)->toBe('2024-01-15T10:30:00+00:00');
});
it('handles inactive client status and calculates total pages correctly', function (): void {

View file

@ -117,6 +117,7 @@ describe('Uuid Helper Unit Tests', function (): void {
// THEN
expect($dateTime)->toBeInstanceOf(\DateTimeImmutable::class)
->and($dateTime->getTimezone()->getName())->toBe('UTC')
->and($dateTime->getTimestamp())->toBeLessThanOrEqual(time())
->and($dateTime->getTimestamp())->toBeGreaterThan(1700000000);
});

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Time;
use Psr\Clock\ClockInterface;
use Shared\Time\Clock;
use Shared\Time\FrozenClock;
use Shared\Time\SystemClock;
use Shared\Time\SystemUtcClock;
use Shared\Time\Utc;
covers(
SystemClock::class,
SystemUtcClock::class,
FrozenClock::class,
Utc::class,
);
afterEach(function (): void {
Utc::restore();
});
describe('PSR-20 UTC Clock Unit Tests', function (): void {
it('implements Psr ClockInterface and Clock contract with configured timezone', function (): void {
// GIVEN
$parisClock = new SystemClock(new \DateTimeZone('Europe/Paris'));
$utcClock = new SystemUtcClock();
// WHEN
$parisNow = $parisClock->now();
$utcNow = $utcClock->now();
// THEN
expect($parisClock)->toBeInstanceOf(ClockInterface::class)
->and($parisClock)->toBeInstanceOf(Clock::class)
->and($parisNow->getTimezone()->getName())->toBe('Europe/Paris')
->and($parisClock->timezone()->getName())->toBe('Europe/Paris')
->and($utcNow->getTimezone()->getName())->toBe('UTC');
});
it('parses date strings and normalizes to UTC with SystemUtcClock', function (): void {
// GIVEN
$clock = new SystemUtcClock();
// WHEN
$utcTime = $clock->parse('2026-08-21T15:00:00+02:00');
$startOfDay = $clock->startOfDay('2026-08-21');
$endOfDay = $clock->endOfDay('2026-08-21');
$isoString = $clock->toIso8601($utcTime);
// THEN
expect($utcTime->getTimezone()->getName())->toBe('UTC')
->and($utcTime->format('Y-m-d H:i:s'))->toBe('2026-08-21 13:00:00')
->and($startOfDay->format('Y-m-d H:i:s'))->toBe('2026-08-21 00:00:00')
->and($endOfDay->format('Y-m-d H:i:s.u'))->toBe('2026-08-21 23:59:59.999999')
->and($isoString)->toBe('2026-08-21T13:00:00+00:00');
});
it('allows freezing and manipulating time with FrozenClock', function (): void {
// GIVEN
$fixedTime = new \DateTimeImmutable('2026-08-21 10:00:00', new \DateTimeZone('UTC'));
$frozenClock = new FrozenClock($fixedTime);
// THEN
expect($frozenClock->now()->format('Y-m-d H:i:s'))->toBe('2026-08-21 10:00:00');
// WHEN
$frozenClock->setTo('2026-12-31 23:59:59');
// THEN
expect($frozenClock->now()->format('Y-m-d H:i:s'))->toBe('2026-12-31 23:59:59')
->and($frozenClock->now()->getTimezone()->getName())->toBe('UTC');
});
it('provides centralized static access through Utc facade', function (): void {
// GIVEN
$frozen = Utc::freeze(new \DateTimeImmutable('2026-08-21 14:00:00', new \DateTimeZone('UTC')));
// THEN
expect(Utc::now()->format('Y-m-d H:i:s'))->toBe('2026-08-21 14:00:00')
->and(Utc::parse('2026-08-21 12:00:00')->format('Y-m-d H:i:s'))->toBe('2026-08-21 12:00:00')
->and(Utc::startOfDay('2026-08-21')->format('Y-m-d H:i:s'))->toBe('2026-08-21 00:00:00')
->and(Utc::endOfDay('2026-08-21')->format('Y-m-d H:i:s'))->toBe('2026-08-21 23:59:59')
->and(Utc::toRfc3339('2026-08-21 14:00:00'))->toBe('2026-08-21T14:00:00+00:00')
->and(Utc::toNullableRfc3339(null))->toBeNull()
->and(Utc::toNullableRfc3339('2026-08-21 14:00:00'))->toBe('2026-08-21T14:00:00+00:00');
// WHEN
Utc::restore();
// THEN
expect(Utc::getClock())->toBeInstanceOf(SystemUtcClock::class);
});
});