Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/openapi/am-client-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions docs/openapi/receptacle-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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));
}
}
11 changes: 10 additions & 1 deletion src/Core/Dto/CreateInstanceHandlerResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
27 changes: 26 additions & 1 deletion src/Core/Dto/OrchestrationCallbackRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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,
);
}

Expand All @@ -85,6 +102,11 @@ public function location(): ?string
return $this->location;
}

public function startedAt(): ?string
{
return $this->startedAt;
}

/**
* @return array<string, mixed>
*/
Expand All @@ -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;
}
Expand Down
37 changes: 36 additions & 1 deletion src/Core/Dto/OrchestrationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ final class OrchestrationCommand
/** @var array<string, mixed> */
private $metadata;

/** @var array<string, mixed> */
private $stateView;

/**
* @param array<string, mixed> $metadata
* @param array<string, mixed> $stateView
*/
public function __construct(
Operation $operation,
Expand All @@ -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;
Expand All @@ -63,6 +68,7 @@ public function __construct(
$this->name = $name;
$this->credentialsLogin = $credentialsLogin;
$this->metadata = $metadata;
$this->stateView = $stateView;
}

/**
Expand Down Expand Up @@ -103,6 +109,7 @@ public static function fromArray(array $data): self
$name,
$credentialsLogin,
$metadata,
self::parseStateView($data),
);
}

Expand Down Expand Up @@ -154,6 +161,14 @@ public function metadata(): array
return $this->metadata;
}

/**
* @return array<string, mixed>
*/
public function stateView(): array
{
return $this->stateView;
}

/**
* @return array<string, mixed>
*/
Expand All @@ -178,6 +193,9 @@ public function toArray(): array
}
$payload['metadata'] = $this->metadata;
}
if ([] !== $this->stateView) {
$payload['stateView'] = $this->stateView;
}

return $payload;
}
Expand Down Expand Up @@ -264,4 +282,21 @@ private static function parseMetadata(array $data): array

return $data['metadata'];
}

/**
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
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'];
}
}
17 changes: 15 additions & 2 deletions src/Core/Orchestration/OrchestrationCommandProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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];
Expand All @@ -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());
Expand Down Expand Up @@ -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');
}
}
}
31 changes: 31 additions & 0 deletions tests/Unit/Dto/OrchestrationCallbackRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
44 changes: 44 additions & 0 deletions tests/Unit/Dto/OrchestrationCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
23 changes: 23 additions & 0 deletions tests/Unit/Http/AmApiClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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<string, mixed> $options
*/
Expand Down
Loading
Loading