diff --git a/docs/openapi/am-client-v1.yaml b/docs/openapi/am-client-v1.yaml index d417ac1..39713c0 100644 --- a/docs/openapi/am-client-v1.yaml +++ b/docs/openapi/am-client-v1.yaml @@ -184,3 +184,9 @@ components: format: uri nullable: true description: URL d'accès tenant (CREATE_INSTANCE SUCCEEDED). + startedAt: + type: string + format: date-time + description: > + Horodatage de mise en service côté app gérée. + Obligatoire sur CREATE_INSTANCE SUCCEEDED. diff --git a/docs/openapi/receptacle-v1.yaml b/docs/openapi/receptacle-v1.yaml index c63d2a9..1ed0b45 100644 --- a/docs/openapi/receptacle-v1.yaml +++ b/docs/openapi/receptacle-v1.yaml @@ -365,6 +365,12 @@ components: type: string description: Nom de la formule au moment de la souscription. example: Pro + stateView: + type: object + description: > + Vue instance optionnelle (formule, ressources, dates…). Objet libre ; + accepté sur toutes les opérations. + additionalProperties: true InstanceOperationalState: type: object diff --git a/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php b/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php index ddf19fc..5352c91 100644 --- a/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php +++ b/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php @@ -7,6 +7,8 @@ use ApplicationManagerTools\AmDriver\Core\Contract\CreateInstanceHandlerInterface; use ApplicationManagerTools\AmDriver\Core\Dto\CreateInstanceHandlerResult; use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCommand; +use DateTimeImmutable; +use DateTimeZone; final class LoggingCreateInstanceHandler implements CreateInstanceHandlerInterface { @@ -22,6 +24,6 @@ public function handle(OrchestrationCommand $command): CreateInstanceHandlerResu { $this->log->add('CREATE_INSTANCE', $command); - return new CreateInstanceHandlerResult(); + return new CreateInstanceHandlerResult(null, (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(\DATE_ATOM)); } } diff --git a/src/Core/Dto/CreateInstanceHandlerResult.php b/src/Core/Dto/CreateInstanceHandlerResult.php index 2dd69f0..aca22bc 100644 --- a/src/Core/Dto/CreateInstanceHandlerResult.php +++ b/src/Core/Dto/CreateInstanceHandlerResult.php @@ -9,13 +9,22 @@ final class CreateInstanceHandlerResult /** @var string|null */ private $instanceLocation; - public function __construct(?string $instanceLocation = null) + /** @var string|null */ + private $startedAt; + + public function __construct(?string $instanceLocation = null, ?string $startedAt = null) { $this->instanceLocation = $instanceLocation; + $this->startedAt = $startedAt; } public function instanceLocation(): ?string { return $this->instanceLocation; } + + public function startedAt(): ?string + { + return $this->startedAt; + } } diff --git a/src/Core/Dto/OrchestrationCallbackRequest.php b/src/Core/Dto/OrchestrationCallbackRequest.php index 30c25f5..cfdd9d1 100644 --- a/src/Core/Dto/OrchestrationCallbackRequest.php +++ b/src/Core/Dto/OrchestrationCallbackRequest.php @@ -22,16 +22,21 @@ final class OrchestrationCallbackRequest /** @var string|null */ private $location; + /** @var string|null */ + private $startedAt; + public function __construct( string $idempotencyKey, CallbackStatus $status, ?string $message = null, - ?string $location = null + ?string $location = null, + ?string $startedAt = null ) { $this->idempotencyKey = $idempotencyKey; $this->status = $status; $this->message = $message; $this->location = $location; + $this->startedAt = $startedAt; } /** @@ -57,11 +62,23 @@ public static function fromArray(array $data): self } } + $startedAt = null; + if (\array_key_exists('startedAt', $data)) { + if (!\is_string($data['startedAt'])) { + throw new InvalidArgumentException('startedAt must be a non-empty string.'); + } + $trimmedStartedAt = trim($data['startedAt']); + if ('' !== $trimmedStartedAt) { + $startedAt = $trimmedStartedAt; + } + } + return new self( (string) $data['idempotencyKey'], CallbackStatus::fromString((string) $data['status']), $message, $location, + $startedAt, ); } @@ -85,6 +102,11 @@ public function location(): ?string return $this->location; } + public function startedAt(): ?string + { + return $this->startedAt; + } + /** * @return array */ @@ -100,6 +122,9 @@ public function toArray(): array if (null !== $this->location) { $payload['location'] = $this->location; } + if (null !== $this->startedAt) { + $payload['startedAt'] = $this->startedAt; + } return $payload; } diff --git a/src/Core/Dto/OrchestrationCommand.php b/src/Core/Dto/OrchestrationCommand.php index 15b5fe3..81350fe 100644 --- a/src/Core/Dto/OrchestrationCommand.php +++ b/src/Core/Dto/OrchestrationCommand.php @@ -40,8 +40,12 @@ final class OrchestrationCommand /** @var array */ private $metadata; + /** @var array */ + private $stateView; + /** * @param array $metadata + * @param array $stateView */ public function __construct( Operation $operation, @@ -52,7 +56,8 @@ public function __construct( ?string $correlationId = null, ?string $name = null, ?string $credentialsLogin = null, - array $metadata = [] + array $metadata = [], + array $stateView = [] ) { $this->operation = $operation; $this->appId = $appId; @@ -63,6 +68,7 @@ public function __construct( $this->name = $name; $this->credentialsLogin = $credentialsLogin; $this->metadata = $metadata; + $this->stateView = $stateView; } /** @@ -103,6 +109,7 @@ public static function fromArray(array $data): self $name, $credentialsLogin, $metadata, + self::parseStateView($data), ); } @@ -154,6 +161,14 @@ public function metadata(): array return $this->metadata; } + /** + * @return array + */ + public function stateView(): array + { + return $this->stateView; + } + /** * @return array */ @@ -178,6 +193,9 @@ public function toArray(): array } $payload['metadata'] = $this->metadata; } + if ([] !== $this->stateView) { + $payload['stateView'] = $this->stateView; + } return $payload; } @@ -264,4 +282,21 @@ private static function parseMetadata(array $data): array return $data['metadata']; } + + /** + * @param array $data + * + * @return array + */ + private static function parseStateView(array $data): array + { + if (!\array_key_exists('stateView', $data)) { + return []; + } + if (!\is_array($data['stateView'])) { + throw new ValidationException('Field stateView must be an object'); + } + + return $data['stateView']; + } } diff --git a/src/Core/Orchestration/OrchestrationCommandProcessor.php b/src/Core/Orchestration/OrchestrationCommandProcessor.php index 2c133a3..2f0b1ed 100644 --- a/src/Core/Orchestration/OrchestrationCommandProcessor.php +++ b/src/Core/Orchestration/OrchestrationCommandProcessor.php @@ -84,6 +84,7 @@ public function process(OrchestrationCommand $command): array try { if ($command->operation()->isCreate()) { $createResult = $this->createHandler->handle($command); + $this->assertCreateInstanceStartedAt($createResult); } elseif ($command->operation()->isStop()) { $this->stopHandler->handle($command); } elseif ($command->operation()->isStart()) { @@ -117,6 +118,7 @@ public function process(OrchestrationCommand $command): array CallbackStatus::succeeded(), null, $createResult instanceof CreateInstanceHandlerResult ? $createResult->instanceLocation() : null, + $createResult instanceof CreateInstanceHandlerResult ? $createResult->startedAt() : null, ); return ['httpStatus' => 200, 'alreadyProcessed' => false]; @@ -126,12 +128,14 @@ public function executeCreateInstance(OrchestrationCommand $command): void { try { $createResult = $this->createHandler->handle($command); + $this->assertCreateInstanceStartedAt($createResult); $this->idempotencyStore->remember($command->idempotencyKey()); $this->reportCallback( $command, CallbackStatus::succeeded(), null, $createResult->instanceLocation(), + $createResult->startedAt(), ); } catch (HandlerFailedException $e) { $this->reportCallback($command, $e->callbackStatus(), $e->getMessage()); @@ -176,10 +180,19 @@ private function reportCallback( OrchestrationCommand $command, CallbackStatus $status, ?string $message, - ?string $location = null + ?string $location = null, + ?string $startedAt = null ): void { $this->amApiClient->reportOrchestrationCallback( - new OrchestrationCallbackRequest($command->idempotencyKey(), $status, $message, $location), + new OrchestrationCallbackRequest($command->idempotencyKey(), $status, $message, $location, $startedAt), ); } + + private function assertCreateInstanceStartedAt(CreateInstanceHandlerResult $createResult): void + { + $startedAt = $createResult->startedAt(); + if (null === $startedAt || '' === trim($startedAt)) { + throw new ValidationException('CREATE_INSTANCE success requires startedAt from handler'); + } + } } diff --git a/tests/Unit/Dto/OrchestrationCallbackRequestTest.php b/tests/Unit/Dto/OrchestrationCallbackRequestTest.php index 37e9ff9..67a5c9b 100644 --- a/tests/Unit/Dto/OrchestrationCallbackRequestTest.php +++ b/tests/Unit/Dto/OrchestrationCallbackRequestTest.php @@ -68,4 +68,35 @@ public function testFromArrayRejectsInvalidLocationUri(): void 'location' => 'not-a-uri', ]); } + + public function testToArrayIncludesStartedAtWhenSet(): void + { + // Arrange + $request = new OrchestrationCallbackRequest( + 'idem-1', + CallbackStatus::succeeded(), + null, + 'https://tenant.example/login', + '2026-06-26T10:05:00+00:00', + ); + + // Act + $array = $request->toArray(); + + // Assert + self::assertSame('2026-06-26T10:05:00+00:00', $array['startedAt']); + } + + public function testFromArrayAcceptsStartedAt(): void + { + // Arrange + $request = OrchestrationCallbackRequest::fromArray([ + 'idempotencyKey' => 'idem-1', + 'status' => 'SUCCEEDED', + 'startedAt' => '2026-06-26T10:05:00+00:00', + ]); + + // Assert + self::assertSame('2026-06-26T10:05:00+00:00', $request->startedAt()); + } } diff --git a/tests/Unit/Dto/OrchestrationCommandTest.php b/tests/Unit/Dto/OrchestrationCommandTest.php index 717c16c..e1ca80c 100644 --- a/tests/Unit/Dto/OrchestrationCommandTest.php +++ b/tests/Unit/Dto/OrchestrationCommandTest.php @@ -172,4 +172,48 @@ public function testFromArrayCreateInstanceWithBillingMetadata(): void self::assertSame($metadata, $command->metadata()); self::assertSame($metadata, $command->toArray()['metadata']); } + + public function testFromArrayStopInstanceWithStateView(): void + { + // Arrange + $stateView = [ + 'state' => 'started', + 'name' => 'Campus 26', + 'instanceId' => 'am_ins_10000000-0000-4000-8000-000000000001', + 'resources' => ['Mo' => ['limit' => 100, 'actual' => 0, 'remaining' => 100]], + ]; + $data = $this->basePayload(Operation::STOP_INSTANCE) + ['stateView' => $stateView]; + + // Act + $command = OrchestrationCommand::fromArray($data); + + // Assert + self::assertSame($stateView, $command->stateView()); + self::assertSame($stateView, $command->toArray()['stateView']); + } + + public function testFromArrayCreateInstanceWithStateView(): void + { + // Arrange + $stateView = ['state' => 'pending', 'name' => 'Campus 26']; + $data = $this->basePayload() + ['stateView' => $stateView, 'metadata' => []]; + + // Act + $command = OrchestrationCommand::fromArray($data); + + // Assert + self::assertSame($stateView, $command->stateView()); + } + + public function testFromArrayRejectsInvalidStateViewType(): void + { + // Arrange + $data = $this->basePayload(Operation::STOP_INSTANCE) + ['stateView' => 'invalid']; + + // Act + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('stateView'); + + OrchestrationCommand::fromArray($data); + } } diff --git a/tests/Unit/Http/AmApiClientTest.php b/tests/Unit/Http/AmApiClientTest.php index 5d5a9fc..59ece45 100644 --- a/tests/Unit/Http/AmApiClientTest.php +++ b/tests/Unit/Http/AmApiClientTest.php @@ -76,6 +76,29 @@ public function testReportCallbackSerializesLocationInJsonBody(): void self::assertSame('https://tenant.example/login', $json['location'] ?? null); } + public function testReportCallbackSerializesStartedAtInJsonBody(): void + { + // Arrange + $recording = new RecordingHttpClient(); + $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-app')); + + // Act + $api->reportOrchestrationCallback(new OrchestrationCallbackRequest( + 'idem-key', + CallbackStatus::succeeded(), + null, + 'https://tenant.example/login', + '2026-06-26T10:05:00+00:00', + )); + + // Assert + $body = $recording->options['body'] ?? null; + self::assertIsString($body); + /** @var array $json */ + $json = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + self::assertSame('2026-06-26T10:05:00+00:00', $json['startedAt'] ?? null); + } + /** * @param array $options */ diff --git a/tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php b/tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php index a82aaa3..d82fc43 100644 --- a/tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php +++ b/tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php @@ -30,9 +30,78 @@ public function testCreateInstanceSuccessPassesLocationToCallback(): void self::assertCount(1, $callbacks); self::assertSame('https://tenant.example/login', $callbacks[0]['location'] ?? null); + self::assertSame('2026-06-26T10:05:00+00:00', $callbacks[0]['startedAt'] ?? null); self::assertSame('SUCCEEDED', $callbacks[0]['status'] ?? null); } + public function testCreateInstanceSuccessWithoutStartedAtFails(): void + { + $command = $this->createCommand(); + $callbacks = []; + $processor = new OrchestrationCommandProcessor( + new class implements CreateInstanceHandlerInterface { + public function handle(OrchestrationCommand $command): CreateInstanceHandlerResult + { + return new CreateInstanceHandlerResult('https://tenant.example/login'); + } + }, + new class implements StopInstanceHandlerInterface { + public function handle(OrchestrationCommand $command): void + { + } + }, + new class implements StartInstanceHandlerInterface { + public function handle(OrchestrationCommand $command): void + { + } + }, + new class implements IdempotencyStoreInterface { + public function has(string $idempotencyKey): bool + { + return false; + } + + public function remember(string $idempotencyKey): void + { + } + }, + new class($callbacks) implements AmApiClientInterface { + /** @var list> */ + private $callbacks; + + /** @param list> $callbacks */ + public function __construct(array &$callbacks) + { + $this->callbacks = &$callbacks; + } + + public function pushConsumption($event): array + { + return ['statusCode' => 202, 'body' => '']; + } + + public function reportOrchestrationCallback($request): array + { + $this->callbacks[] = $request->toArray(); + + return ['statusCode' => 202, 'body' => '']; + } + }, + new InMemoryLifecycleStore(), + new class implements DeferredCreateInstanceDispatcherInterface { + public function dispatch(OrchestrationCommand $command): void + { + } + }, + ); + + $result = $processor->process($command); + + self::assertSame(400, $result['httpStatus']); + self::assertCount(1, $callbacks); + self::assertSame('FAILED', $callbacks[0]['status'] ?? null); + } + public function testDeferredCreateInstanceDoesNotCallbackOnProcess(): void { $command = $this->createCommand(); @@ -80,6 +149,7 @@ public function testDeferredExecuteCreateInstanceCallbacksWithLocation(): void self::assertCount(1, $callbacks); self::assertSame('https://tenant.example/login', $callbacks[0]['location'] ?? null); + self::assertSame('2026-06-26T10:05:00+00:00', $callbacks[0]['startedAt'] ?? null); self::assertSame('SUCCEEDED', $callbacks[0]['status'] ?? null); } @@ -240,7 +310,10 @@ private function processor( new class implements CreateInstanceHandlerInterface { public function handle(OrchestrationCommand $command): CreateInstanceHandlerResult { - return new CreateInstanceHandlerResult('https://tenant.example/login'); + return new CreateInstanceHandlerResult( + 'https://tenant.example/login', + '2026-06-26T10:05:00+00:00', + ); } }, new class implements StopInstanceHandlerInterface {