feat(shared): implement PSR-20 Clock contract, Utc facade, and RFC3339 normalization
This commit is contained in:
parent
1df3a27c98
commit
37bd6cdbdd
44 changed files with 761 additions and 145 deletions
|
|
@ -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
2
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
75
src/Infra/Casts/UtcDateTimeCast.php
Normal file
75
src/Infra/Casts/UtcDateTimeCast.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
25
src/Shared/Time/Clock.php
Normal 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;
|
||||
}
|
||||
86
src/Shared/Time/FrozenClock.php
Normal file
86
src/Shared/Time/FrozenClock.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/Shared/Time/SystemClock.php
Normal file
76
src/Shared/Time/SystemClock.php
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/Shared/Time/SystemUtcClock.php
Normal file
13
src/Shared/Time/SystemUtcClock.php
Normal 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
73
src/Shared/Time/Utc.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
112
tests/Unit/Infra/Casts/UtcDateTimeCastTest.php
Normal file
112
tests/Unit/Infra/Casts/UtcDateTimeCastTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
96
tests/Unit/Shared/Time/ClockTest.php
Normal file
96
tests/Unit/Shared/Time/ClockTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue