diff --git a/composer.json b/composer.json index 322ee9c..bbcfaf0 100644 --- a/composer.json +++ b/composer.json @@ -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" }, diff --git a/composer.lock b/composer.lock index e61673e..e1623fd 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/src/Application/Security/Models/Entities/Client.php b/src/Application/Security/Models/Entities/Client.php index d258ac1..e6a3725 100644 --- a/src/Application/Security/Models/Entities/Client.php +++ b/src/Application/Security/Models/Entities/Client.php @@ -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 @@ -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; } diff --git a/src/Application/Security/Usecases/CreateClient/CreateClient.php b/src/Application/Security/Usecases/CreateClient/CreateClient.php index c660c0e..05960d4 100644 --- a/src/Application/Security/Usecases/CreateClient/CreateClient.php +++ b/src/Application/Security/Usecases/CreateClient/CreateClient.php @@ -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( diff --git a/src/Application/Security/Usecases/GetSecurityAuditLogs/GetSecurityAuditLogs.php b/src/Application/Security/Usecases/GetSecurityAuditLogs/GetSecurityAuditLogs.php index 166f03f..8d1bf08 100644 --- a/src/Application/Security/Usecases/GetSecurityAuditLogs/GetSecurityAuditLogs.php +++ b/src/Application/Security/Usecases/GetSecurityAuditLogs/GetSecurityAuditLogs.php @@ -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, diff --git a/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php b/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php index 1b1947a..984cceb 100644 --- a/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php +++ b/src/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecret.php @@ -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); diff --git a/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php b/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php index 7686c4f..bb6a25d 100644 --- a/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php +++ b/src/Application/Security/Usecases/ToggleClientStatus/SetClientStatus.php @@ -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(), )); } diff --git a/src/Infra/Adapters/Directories/EloquentEmployeeDirectory.php b/src/Infra/Adapters/Directories/EloquentEmployeeDirectory.php index dc5293a..cf17252 100644 --- a/src/Infra/Adapters/Directories/EloquentEmployeeDirectory.php +++ b/src/Infra/Adapters/Directories/EloquentEmployeeDirectory.php @@ -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) { diff --git a/src/Infra/Adapters/Services/TimestampValidatorImpl.php b/src/Infra/Adapters/Services/TimestampValidatorImpl.php index 4983ccd..dc6b624 100644 --- a/src/Infra/Adapters/Services/TimestampValidatorImpl.php +++ b/src/Infra/Adapters/Services/TimestampValidatorImpl.php @@ -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 diff --git a/src/Infra/Casts/UtcDateTimeCast.php b/src/Infra/Casts/UtcDateTimeCast.php new file mode 100644 index 0000000..cecd4ba --- /dev/null +++ b/src/Infra/Casts/UtcDateTimeCast.php @@ -0,0 +1,75 @@ + */ +final class UtcDateTimeCast implements CastsAttributes +{ + /** + * Cast the given value from the database to DateTimeImmutable in UTC. + * + * @param array $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 $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))); + } +} diff --git a/src/Infra/Database/Models/ApiClient.php b/src/Infra/Database/Models/ApiClient.php index 942e0e8..90ab502 100644 --- a/src/Infra/Database/Models/ApiClient.php +++ b/src/Infra/Database/Models/ApiClient.php @@ -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 $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, ]; } } diff --git a/src/Infra/Database/Models/ClientAuditLog.php b/src/Infra/Database/Models/ClientAuditLog.php index acaff3c..04ddf26 100644 --- a/src/Infra/Database/Models/ClientAuditLog.php +++ b/src/Infra/Database/Models/ClientAuditLog.php @@ -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, ]; } } diff --git a/src/Infra/Database/Models/Employee.php b/src/Infra/Database/Models/Employee.php index 01a19b9..5bcf9ee 100644 --- a/src/Infra/Database/Models/Employee.php +++ b/src/Infra/Database/Models/Employee.php @@ -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, ]; } } diff --git a/src/Infra/Providers/AppServiceProvider.php b/src/Infra/Providers/AppServiceProvider.php index 8a16901..d17ee21 100644 --- a/src/Infra/Providers/AppServiceProvider.php +++ b/src/Infra/Providers/AppServiceProvider.php @@ -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'); diff --git a/src/Infra/Providers/ConfigServiceProvider.php b/src/Infra/Providers/ConfigServiceProvider.php index 42528e1..f073531 100644 --- a/src/Infra/Providers/ConfigServiceProvider.php +++ b/src/Infra/Providers/ConfigServiceProvider.php @@ -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')), diff --git a/src/Infra/Providers/SecurityServiceProvider.php b/src/Infra/Providers/SecurityServiceProvider.php index 3c5bb90..ff43d1b 100644 --- a/src/Infra/Providers/SecurityServiceProvider.php +++ b/src/Infra/Providers/SecurityServiceProvider.php @@ -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); diff --git a/src/Presentation/Api/V1/Dto/Brands/BrandDto.php b/src/Presentation/Api/V1/Dto/Brands/BrandDto.php index 96600b0..3034656 100644 --- a/src/Presentation/Api/V1/Dto/Brands/BrandDto.php +++ b/src/Presentation/Api/V1/Dto/Brands/BrandDto.php @@ -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), ); } diff --git a/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php b/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php index 65e970e..ae29155 100644 --- a/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php +++ b/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php @@ -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; diff --git a/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php b/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php index d203503..715e301 100644 --- a/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php +++ b/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php @@ -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'); diff --git a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php index a6972a3..eabb427 100644 --- a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php +++ b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php @@ -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), ); } } diff --git a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsSearchResponse.php b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsSearchResponse.php index 40c8dd0..2ca2059 100644 --- a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsSearchResponse.php +++ b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsSearchResponse.php @@ -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), ); } } diff --git a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php index b073973..60b488e 100644 --- a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php +++ b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php @@ -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), ); } } diff --git a/src/Presentation/Console/Commands/ExportSecurityAuditLogsCommand.php b/src/Presentation/Console/Commands/ExportSecurityAuditLogsCommand.php index af8e8a4..a8baa3c 100644 --- a/src/Presentation/Console/Commands/ExportSecurityAuditLogsCommand.php +++ b/src/Presentation/Console/Commands/ExportSecurityAuditLogsCommand.php @@ -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()); diff --git a/src/Presentation/Console/Presenters/Brands/ListBrandsPresenter.php b/src/Presentation/Console/Presenters/Brands/ListBrandsPresenter.php index 9462292..6c6b2ec 100644 --- a/src/Presentation/Console/Presenters/Brands/ListBrandsPresenter.php +++ b/src/Presentation/Console/Presenters/Brands/ListBrandsPresenter.php @@ -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 */ @@ -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 )); diff --git a/src/Presentation/Console/Presenters/Clients/ListClientsPresenter.php b/src/Presentation/Console/Presenters/Clients/ListClientsPresenter.php index fccac4c..333dcab 100644 --- a/src/Presentation/Console/Presenters/Clients/ListClientsPresenter.php +++ b/src/Presentation/Console/Presenters/Clients/ListClientsPresenter.php @@ -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 */ @@ -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 )); diff --git a/src/Shared/Helpers/DatesHelper.php b/src/Shared/Helpers/DatesHelper.php index feda33c..3c24d95 100644 --- a/src/Shared/Helpers/DatesHelper.php +++ b/src/Shared/Helpers/DatesHelper.php @@ -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); } diff --git a/src/Shared/Helpers/UuidHelper.php b/src/Shared/Helpers/UuidHelper.php index 9a02cd3..cd4861a 100644 --- a/src/Shared/Helpers/UuidHelper.php +++ b/src/Shared/Helpers/UuidHelper.php @@ -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')); } } diff --git a/src/Shared/Time/Clock.php b/src/Shared/Time/Clock.php new file mode 100644 index 0000000..ddd7309 --- /dev/null +++ b/src/Shared/Time/Clock.php @@ -0,0 +1,25 @@ +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); + } +} diff --git a/src/Shared/Time/SystemClock.php b/src/Shared/Time/SystemClock.php new file mode 100644 index 0000000..84e1cfc --- /dev/null +++ b/src/Shared/Time/SystemClock.php @@ -0,0 +1,76 @@ +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); + } +} diff --git a/src/Shared/Time/SystemUtcClock.php b/src/Shared/Time/SystemUtcClock.php new file mode 100644 index 0000000..376912c --- /dev/null +++ b/src/Shared/Time/SystemUtcClock.php @@ -0,0 +1,13 @@ +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); + } +} diff --git a/tests/Architecture/CodingStandardsTest.php b/tests/Architecture/CodingStandardsTest.php index 232f48c..e5c2506 100644 --- a/tests/Architecture/CodingStandardsTest.php +++ b/tests/Architecture/CodingStandardsTest.php @@ -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(); diff --git a/tests/Feature/Console/Commands/ListClientsCommandTest.php b/tests/Feature/Console/Commands/ListClientsCommandTest.php index b8deee6..cb06df5 100644 --- a/tests/Feature/Console/Commands/ListClientsCommandTest.php +++ b/tests/Feature/Console/Commands/ListClientsCommandTest.php @@ -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), ], ] ) diff --git a/tests/Integration/Infra/Adapters/Directories/EloquentEmployeeDirectoryTest.php b/tests/Integration/Infra/Adapters/Directories/EloquentEmployeeDirectoryTest.php index bbaf98e..96b957f 100644 --- a/tests/Integration/Infra/Adapters/Directories/EloquentEmployeeDirectoryTest.php +++ b/tests/Integration/Infra/Adapters/Directories/EloquentEmployeeDirectoryTest.php @@ -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(); diff --git a/tests/Integration/Infra/Providers/ConfigServiceProviderTest.php b/tests/Integration/Infra/Providers/ConfigServiceProviderTest.php index 4bee6f9..1e9683b 100644 --- a/tests/Integration/Infra/Providers/ConfigServiceProviderTest.php +++ b/tests/Integration/Infra/Providers/ConfigServiceProviderTest.php @@ -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'); + }); }); diff --git a/tests/Integration/Shared/Helpers/DateHelperTest.php b/tests/Integration/Shared/Helpers/DateHelperTest.php index fb148c6..cefb089 100644 --- a/tests/Integration/Shared/Helpers/DateHelperTest.php +++ b/tests/Integration/Shared/Helpers/DateHelperTest.php @@ -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'); }); }); diff --git a/tests/Unit/Infra/Adapters/Services/TimestampValidatorImplTest.php b/tests/Unit/Infra/Adapters/Services/TimestampValidatorImplTest.php index 138c148..ed17d2e 100644 --- a/tests/Unit/Infra/Adapters/Services/TimestampValidatorImplTest.php +++ b/tests/Unit/Infra/Adapters/Services/TimestampValidatorImplTest.php @@ -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); diff --git a/tests/Unit/Infra/Casts/UtcDateTimeCastTest.php b/tests/Unit/Infra/Casts/UtcDateTimeCastTest.php new file mode 100644 index 0000000..88be180 --- /dev/null +++ b/tests/Unit/Infra/Casts/UtcDateTimeCastTest.php @@ -0,0 +1,112 @@ +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'); + }); +}); diff --git a/tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php b/tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php index bbc48a2..e39cd2a 100644 --- a/tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php +++ b/tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php @@ -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), ]); }); diff --git a/tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php b/tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php index 73e0af0..329f9a2 100644 --- a/tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php +++ b/tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php @@ -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 { diff --git a/tests/Unit/Presentation/Console/Presenters/Clients/ListClientsPresenterTest.php b/tests/Unit/Presentation/Console/Presenters/Clients/ListClientsPresenterTest.php index 5920f91..0ae2639 100644 --- a/tests/Unit/Presentation/Console/Presenters/Clients/ListClientsPresenterTest.php +++ b/tests/Unit/Presentation/Console/Presenters/Clients/ListClientsPresenterTest.php @@ -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 { diff --git a/tests/Unit/Shared/Helpers/UuidHelperTest.php b/tests/Unit/Shared/Helpers/UuidHelperTest.php index 8728e41..4e4d3ad 100644 --- a/tests/Unit/Shared/Helpers/UuidHelperTest.php +++ b/tests/Unit/Shared/Helpers/UuidHelperTest.php @@ -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); }); diff --git a/tests/Unit/Shared/Time/ClockTest.php b/tests/Unit/Shared/Time/ClockTest.php new file mode 100644 index 0000000..879eace --- /dev/null +++ b/tests/Unit/Shared/Time/ClockTest.php @@ -0,0 +1,96 @@ +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); + }); +});