From d34824571c11d6c2c085775f672c9930d032fae2 Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 05:46:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(idempotency):=20univeros/idempotency=20?= =?UTF-8?q?=E2=80=94=20storage=20contract=20+=20adapters=20(#172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the storage layer the rest of the Idempotency-Key epic builds on: a small IdempotencyStoreInterface + a StoredResponse value object + three adapters covering the realistic deployment shapes. Sub-package layout (mirrors univeros/cookie, univeros/session, etc.) - src/Altair/Idempotency/Contracts/IdempotencyStoreInterface.php - src/Altair/Idempotency/Storage/{StoredResponse,InMemoryStore, ApcuStore,RedisStore}.php - src/Altair/Idempotency/Exception/IdempotencyException.php - src/Altair/Idempotency/composer.json (require php >=8.3; ext-apcu / ext-redis declared as suggestions) - Wired into root composer.json's replace block. Contract - claim(key, requestHash, ttl): atomic; null = caller owns the key and must execute, StoredResponse = key already present (replay or in-progress, distinguished by `inProgress`). - complete(key, response, ttl): persists the captured response under a previously-claimed key. - release(key): drops the claim — used when the handler threw, so the next attempt starts fresh. - get(key): read-only inspection. Adapters - InMemoryStore: process-local; suitable for tests + single-worker scripts. Injectable clock for deterministic TTL tests. - ApcuStore: single-host production. apcu_add is the atomic claim; apcu_store overwrites on complete. Throws at construction time when ext-apcu is unavailable rather than silently degrading. - RedisStore: multi-host production. SET key value NX EX ttl is the atomic claim; constructor consumes a pre-configured \Redis client so connection lifecycle stays the host's responsibility. StoredResponse - Final readonly value object with toArray/fromArray + toJson/fromJson. - inProgress / completed named constructors keep call sites readable. - Malformed header rows on hydration are dropped rather than thrown to keep replay robust against partial stored writes. Tests (26 new) - StoredResponse: round-trip via array + JSON + missing-field / malformed-input rejection paths. - InMemoryStore: full coverage including expired-entry reaping via the injectable clock. - ApcuStore: skipped when ext-apcu (CLI-enabled) is absent; otherwise covers fresh-claim, concurrent-claim, complete-then-get, release, and per-prefix namespace isolation. - RedisStore: skipped when ext-redis is absent or no Redis reachable; uses db 15 with flushDB cleanup so it doesn't trample real data. Out of scope (per #171 epic) - The PSR-15 middleware that drives the store — #173. - The idempotency: spec block — #174. - The x-altair-idempotency round-trip activation — #175. - The package doc + benchmark variant — #176. Part of #171. --- .agent/MANIFEST.md | 1 + .agent/packages/idempotency.md | 26 +++ composer.json | 1 + .../Contracts/IdempotencyStoreInterface.php | 74 ++++++++ .../Exception/IdempotencyException.php | 22 +++ src/Altair/Idempotency/Storage/ApcuStore.php | 87 ++++++++++ .../Idempotency/Storage/InMemoryStore.php | 85 +++++++++ src/Altair/Idempotency/Storage/RedisStore.php | 106 ++++++++++++ .../Idempotency/Storage/StoredResponse.php | 163 ++++++++++++++++++ src/Altair/Idempotency/composer.json | 22 +++ tests/Idempotency/Storage/ApcuStoreTest.php | 90 ++++++++++ .../Idempotency/Storage/InMemoryStoreTest.php | 98 +++++++++++ tests/Idempotency/Storage/RedisStoreTest.php | 104 +++++++++++ .../Storage/StoredResponseTest.php | 134 ++++++++++++++ 14 files changed, 1013 insertions(+) create mode 100644 .agent/packages/idempotency.md create mode 100644 src/Altair/Idempotency/Contracts/IdempotencyStoreInterface.php create mode 100644 src/Altair/Idempotency/Exception/IdempotencyException.php create mode 100644 src/Altair/Idempotency/Storage/ApcuStore.php create mode 100644 src/Altair/Idempotency/Storage/InMemoryStore.php create mode 100644 src/Altair/Idempotency/Storage/RedisStore.php create mode 100644 src/Altair/Idempotency/Storage/StoredResponse.php create mode 100644 src/Altair/Idempotency/composer.json create mode 100644 tests/Idempotency/Storage/ApcuStoreTest.php create mode 100644 tests/Idempotency/Storage/InMemoryStoreTest.php create mode 100644 tests/Idempotency/Storage/RedisStoreTest.php create mode 100644 tests/Idempotency/Storage/StoredResponseTest.php diff --git a/.agent/MANIFEST.md b/.agent/MANIFEST.md index 0b68123c..8a1e238c 100644 --- a/.agent/MANIFEST.md +++ b/.agent/MANIFEST.md @@ -21,6 +21,7 @@ Machine-readable descriptions of every framework sub-package, generated by `univ | [univeros/filesystem](packages/filesystem.md) | `Altair\Filesystem` | The Altair Filesystem package. | | [univeros/happen](packages/happen.md) | `Altair\Happen` | The Altair Event package. | | [univeros/http](packages/http.md) | `Altair\Http` | The Altair Http package. | +| [univeros/idempotency](packages/idempotency.md) | `Altair\Idempotency` | Stripe-style Idempotency-Key primitive for Univeros: storage contract, adapters, and (in companion packages) PSR-15 middleware + spec block. | | [univeros/index](packages/index.md) | `Altair\Index` | bin/altair index — a symbol-usage index built from the PHP AST plus spec awareness. Answers find-usages, implementers, callers-of, dead-code, and refactor-impact queries in milliseconds, as deterministic JSON for agents and CI. SQLite-backed. | | [univeros/introspection](packages/introspection.md) | `Altair\Introspection` | What's wired into this project right now? CLI commands + inspectors for the Container, routes, listeners, middleware, manifests, specs, and config. | | [univeros/mcp](packages/mcp.md) | `Altair\Mcp` | Model Context Protocol server: exposes the framework's capabilities as MCP tools so any MCP-capable agent can drive an Altair project natively. | diff --git a/.agent/packages/idempotency.md b/.agent/packages/idempotency.md new file mode 100644 index 00000000..f534e9ee --- /dev/null +++ b/.agent/packages/idempotency.md @@ -0,0 +1,26 @@ +# univeros/idempotency · Altair\Idempotency + +**Purpose:** Stripe-style Idempotency-Key primitive for Univeros: storage contract, adapters, and (in companion packages) PSR-15 middleware + spec block. + +## Public contracts + +| Interface | Method | Returns | Notes | +|---|---|---|---| +| `IdempotencyStoreInterface` | `claim(string, string, int)` | `StoredResponse\|null` | | +| | `complete(string, StoredResponse, int)` | `void` | | +| | `get(string)` | `StoredResponse\|null` | | +| | `release(string)` | `void` | | + +## Concrete classes + +- `ApcuStore` _(final)_ — implements `IdempotencyStoreInterface` +- `InMemoryStore` _(final)_ — implements `IdempotencyStoreInterface` +- `RedisStore` _(final)_ — implements `IdempotencyStoreInterface` +- `StoredResponse` _(final)_ + +## Tests as documentation + +- `tests/Idempotency/Storage/ApcuStoreTest.php` +- `tests/Idempotency/Storage/InMemoryStoreTest.php` +- `tests/Idempotency/Storage/RedisStoreTest.php` +- `tests/Idempotency/Storage/StoredResponseTest.php` diff --git a/composer.json b/composer.json index 638875d2..280f9df9 100644 --- a/composer.json +++ b/composer.json @@ -90,6 +90,7 @@ "univeros/filesystem": "self.version", "univeros/happen": "self.version", "univeros/http": "self.version", + "univeros/idempotency": "self.version", "univeros/index": "self.version", "univeros/introspection": "self.version", "univeros/mcp": "self.version", diff --git a/src/Altair/Idempotency/Contracts/IdempotencyStoreInterface.php b/src/Altair/Idempotency/Contracts/IdempotencyStoreInterface.php new file mode 100644 index 00000000..6f8f18d0 --- /dev/null +++ b/src/Altair/Idempotency/Contracts/IdempotencyStoreInterface.php @@ -0,0 +1,74 @@ +qualify($key); + $now = time(); + $entry = StoredResponse::inProgress($requestHash, $now); + + if (apcu_add($fullKey, $entry->toJson(), $ttlSeconds)) { + return null; + } + + return $this->fetch($fullKey); + } + + public function complete(string $key, StoredResponse $response, int $ttlSeconds): void + { + $fullKey = $this->qualify($key); + $stored = apcu_store($fullKey, $response->toJson(), $ttlSeconds); + if ($stored !== true) { + throw new IdempotencyException(\sprintf("ApcuStore::complete() failed to write key '%s'.", $fullKey)); + } + } + + public function release(string $key): void + { + apcu_delete($this->qualify($key)); + } + + public function get(string $key): ?StoredResponse + { + return $this->fetch($this->qualify($key)); + } + + private function fetch(string $fullKey): ?StoredResponse + { + $success = false; + $raw = apcu_fetch($fullKey, $success); + if (!$success || !\is_string($raw)) { + return null; + } + + return StoredResponse::fromJson($raw); + } + + private function qualify(string $key): string + { + return $this->keyPrefix . $key; + } +} diff --git a/src/Altair/Idempotency/Storage/InMemoryStore.php b/src/Altair/Idempotency/Storage/InMemoryStore.php new file mode 100644 index 00000000..86a66dd9 --- /dev/null +++ b/src/Altair/Idempotency/Storage/InMemoryStore.php @@ -0,0 +1,85 @@ + */ + private array $entries = []; + + /** @var callable(): int */ + private $clock; + + /** + * @param ?callable(): int $clock Returns Unix seconds. Defaults to `time()`. + */ + public function __construct(?callable $clock = null) + { + $this->clock = $clock ?? time(...); + } + + public function claim(string $key, string $requestHash, int $ttlSeconds): ?StoredResponse + { + $existing = $this->get($key); + if ($existing instanceof StoredResponse) { + return $existing; + } + + $now = ($this->clock)(); + $this->entries[$key] = [ + 'response' => StoredResponse::inProgress($requestHash, $now), + 'expires_at' => $now + $ttlSeconds, + ]; + + return null; + } + + public function complete(string $key, StoredResponse $response, int $ttlSeconds): void + { + $now = ($this->clock)(); + $this->entries[$key] = [ + 'response' => $response, + 'expires_at' => $now + $ttlSeconds, + ]; + } + + public function release(string $key): void + { + unset($this->entries[$key]); + } + + public function get(string $key): ?StoredResponse + { + if (!isset($this->entries[$key])) { + return null; + } + + $entry = $this->entries[$key]; + if ($entry['expires_at'] <= ($this->clock)()) { + unset($this->entries[$key]); + + return null; + } + + return $entry['response']; + } +} diff --git a/src/Altair/Idempotency/Storage/RedisStore.php b/src/Altair/Idempotency/Storage/RedisStore.php new file mode 100644 index 00000000..6f3f3a73 --- /dev/null +++ b/src/Altair/Idempotency/Storage/RedisStore.php @@ -0,0 +1,106 @@ +qualify($key); + $entry = StoredResponse::inProgress($requestHash, time()); + + try { + $set = $this->redis->set($fullKey, $entry->toJson(), ['NX', 'EX' => $ttlSeconds]); + } catch (RedisException $redisException) { + throw new IdempotencyException('RedisStore::claim() failed: ' . $redisException->getMessage(), 0, $redisException); + } + + if ($set === true) { + return null; + } + + return $this->fetch($fullKey); + } + + public function complete(string $key, StoredResponse $response, int $ttlSeconds): void + { + $fullKey = $this->qualify($key); + + try { + $set = $this->redis->set($fullKey, $response->toJson(), ['EX' => $ttlSeconds]); + } catch (RedisException $redisException) { + throw new IdempotencyException('RedisStore::complete() failed: ' . $redisException->getMessage(), 0, $redisException); + } + + if ($set !== true) { + throw new IdempotencyException(\sprintf("RedisStore::complete() failed to write key '%s'.", $fullKey)); + } + } + + public function release(string $key): void + { + try { + $this->redis->del($this->qualify($key)); + } catch (RedisException $redisException) { + throw new IdempotencyException('RedisStore::release() failed: ' . $redisException->getMessage(), 0, $redisException); + } + } + + public function get(string $key): ?StoredResponse + { + return $this->fetch($this->qualify($key)); + } + + private function fetch(string $fullKey): ?StoredResponse + { + try { + $raw = $this->redis->get($fullKey); + } catch (RedisException $redisException) { + throw new IdempotencyException('RedisStore::get() failed: ' . $redisException->getMessage(), 0, $redisException); + } + + if (!\is_string($raw)) { + return null; + } + + return StoredResponse::fromJson($raw); + } + + private function qualify(string $key): string + { + return $this->keyPrefix . $key; + } +} diff --git a/src/Altair/Idempotency/Storage/StoredResponse.php b/src/Altair/Idempotency/Storage/StoredResponse.php new file mode 100644 index 00000000..c693df38 --- /dev/null +++ b/src/Altair/Idempotency/Storage/StoredResponse.php @@ -0,0 +1,163 @@ +> + */ +final readonly class StoredResponse +{ + /** + * @param HeaderMap $headers + */ + public function __construct( + public string $requestHash, + public int $status, + public array $headers, + public string $body, + public bool $inProgress, + public int $createdAt, + ) {} + + /** + * Marker for a key that has been claimed but whose response has not + * yet been captured. Body / status are empty placeholders until + * {@see self::completed()} replaces the row. + */ + public static function inProgress(string $requestHash, int $createdAt): self + { + return new self( + requestHash: $requestHash, + status: 0, + headers: [], + body: '', + inProgress: true, + createdAt: $createdAt, + ); + } + + /** + * @param HeaderMap $headers + */ + public static function completed( + string $requestHash, + int $status, + array $headers, + string $body, + int $createdAt, + ): self { + return new self( + requestHash: $requestHash, + status: $status, + headers: $headers, + body: $body, + inProgress: false, + createdAt: $createdAt, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'request_hash' => $this->requestHash, + 'status' => $this->status, + 'headers' => $this->headers, + 'body' => $this->body, + 'in_progress' => $this->inProgress, + 'created_at' => $this->createdAt, + ]; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + foreach (['request_hash', 'status', 'headers', 'body', 'in_progress', 'created_at'] as $required) { + if (!\array_key_exists($required, $data)) { + throw new IdempotencyException(\sprintf('StoredResponse is missing required field "%s".', $required)); + } + } + + $headers = $data['headers']; + if (!\is_array($headers)) { + throw new IdempotencyException('StoredResponse "headers" must be an array.'); + } + + /** @var array> $normalisedHeaders */ + $normalisedHeaders = []; + foreach ($headers as $name => $values) { + if (!\is_string($name)) { + continue; + } + + if (!\is_array($values)) { + continue; + } + + $normalisedHeaders[$name] = array_values(array_map(strval(...), $values)); + } + + return new self( + requestHash: (string) $data['request_hash'], + status: (int) $data['status'], + headers: $normalisedHeaders, + body: (string) $data['body'], + inProgress: (bool) $data['in_progress'], + createdAt: (int) $data['created_at'], + ); + } + + public function toJson(): string + { + try { + return json_encode( + $this->toArray(), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ); + } catch (JsonException $jsonException) { + throw new IdempotencyException('StoredResponse is not JSON-encodable: ' . $jsonException->getMessage(), 0, $jsonException); + } + } + + public static function fromJson(string $json): self + { + try { + /** @var mixed $decoded */ + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new IdempotencyException('StoredResponse JSON is malformed: ' . $jsonException->getMessage(), 0, $jsonException); + } + + if (!\is_array($decoded)) { + throw new IdempotencyException('StoredResponse JSON must decode to a map.'); + } + + /** @var array $decoded */ + return self::fromArray($decoded); + } +} diff --git a/src/Altair/Idempotency/composer.json b/src/Altair/Idempotency/composer.json new file mode 100644 index 00000000..c20538e4 --- /dev/null +++ b/src/Altair/Idempotency/composer.json @@ -0,0 +1,22 @@ +{ + "name": "univeros/idempotency", + "description": "Stripe-style Idempotency-Key primitive for Univeros: storage contract, adapters, and (in companion packages) PSR-15 middleware + spec block.", + "license": "MIT", + "homepage": "https://univeros.io", + "support": { + "issues": "https://github.com/univeros/framework/issues", + "source": "https://github.com/univeros/framework" + }, + "require": { + "php": ">=8.3" + }, + "suggest": { + "ext-apcu": "Required for the single-host ApcuStore adapter.", + "ext-redis": "Required for the multi-host RedisStore adapter." + }, + "autoload": { + "psr-4": { + "Altair\\Idempotency\\": "" + } + } +} diff --git a/tests/Idempotency/Storage/ApcuStoreTest.php b/tests/Idempotency/Storage/ApcuStoreTest.php new file mode 100644 index 00000000..ce80d303 --- /dev/null +++ b/tests/Idempotency/Storage/ApcuStoreTest.php @@ -0,0 +1,90 @@ +expectException(IdempotencyException::class); + new ApcuStore(); + } + + public function testClaimFreshKey(): void + { + $store = new ApcuStore(keyPrefix: 'test.idem.'); + + self::assertNull($store->claim('k1', 'hash', 60)); + } + + public function testConcurrentClaimReturnsExistingEntry(): void + { + $store = new ApcuStore(keyPrefix: 'test.idem.'); + $store->claim('k1', 'hash', 60); + + $second = $store->claim('k1', 'hash', 60); + + self::assertInstanceOf(StoredResponse::class, $second); + self::assertTrue($second->inProgress); + } + + public function testCompleteThenGet(): void + { + $store = new ApcuStore(keyPrefix: 'test.idem.'); + $store->claim('k1', 'hash', 60); + + $store->complete('k1', StoredResponse::completed('hash', 200, [], 'body', 0), 60); + + $fetched = $store->get('k1'); + self::assertInstanceOf(StoredResponse::class, $fetched); + self::assertFalse($fetched->inProgress); + self::assertSame('body', $fetched->body); + } + + public function testReleaseDropsClaim(): void + { + $store = new ApcuStore(keyPrefix: 'test.idem.'); + $store->claim('k1', 'hash', 60); + + $store->release('k1'); + + self::assertNull($store->get('k1')); + } + + public function testKeyPrefixIsolatesNamespaces(): void + { + $alpha = new ApcuStore(keyPrefix: 'a.idem.'); + $beta = new ApcuStore(keyPrefix: 'b.idem.'); + + $alpha->claim('same-key', 'hash-a', 60); + + self::assertNull($beta->claim('same-key', 'hash-b', 60), 'different prefixes must not collide'); + } +} diff --git a/tests/Idempotency/Storage/InMemoryStoreTest.php b/tests/Idempotency/Storage/InMemoryStoreTest.php new file mode 100644 index 00000000..ee5af91a --- /dev/null +++ b/tests/Idempotency/Storage/InMemoryStoreTest.php @@ -0,0 +1,98 @@ +claim('key-1', 'hash', 60)); + } + + public function testClaimAfterClaimReturnsInProgressEntry(): void + { + $store = new InMemoryStore(); + $store->claim('key-1', 'hash', 60); + + $second = $store->claim('key-1', 'hash', 60); + + self::assertInstanceOf(StoredResponse::class, $second); + self::assertTrue($second->inProgress); + self::assertSame('hash', $second->requestHash); + } + + public function testCompleteOverwritesInProgressEntry(): void + { + $store = new InMemoryStore(); + $store->claim('key-1', 'hash', 60); + + $store->complete( + 'key-1', + StoredResponse::completed('hash', 201, ['Content-Type' => ['application/json']], '{"ok":true}', 0), + 60, + ); + + $fetched = $store->get('key-1'); + self::assertInstanceOf(StoredResponse::class, $fetched); + self::assertFalse($fetched->inProgress); + self::assertSame(201, $fetched->status); + self::assertSame('{"ok":true}', $fetched->body); + } + + public function testClaimAfterCompleteReturnsCachedResponse(): void + { + $store = new InMemoryStore(); + $store->claim('key-1', 'hash', 60); + $store->complete( + 'key-1', + StoredResponse::completed('hash', 201, [], 'body', 0), + 60, + ); + + $third = $store->claim('key-1', 'hash', 60); + + self::assertInstanceOf(StoredResponse::class, $third); + self::assertFalse($third->inProgress); + self::assertSame('body', $third->body); + } + + public function testReleaseDropsClaim(): void + { + $store = new InMemoryStore(); + $store->claim('key-1', 'hash', 60); + + $store->release('key-1'); + + self::assertNull($store->get('key-1')); + self::assertNull($store->claim('key-1', 'hash', 60), 'release should free the key for a fresh claim'); + } + + public function testGetReturnsNullForUnknownKey(): void + { + self::assertNull((new InMemoryStore())->get('nope')); + } + + public function testExpiredEntriesAreReaped(): void + { + $now = 1_700_000_000; + $clock = static function () use (&$now): int { + return $now; + }; + $store = new InMemoryStore($clock); + + $store->claim('key-1', 'hash', 60); + + $now += 61; + + self::assertNull($store->get('key-1'), 'expired entry should not be returned'); + self::assertNull($store->claim('key-1', 'hash', 60), 'expired entry should let a fresh claim succeed'); + } +} diff --git a/tests/Idempotency/Storage/RedisStoreTest.php b/tests/Idempotency/Storage/RedisStoreTest.php new file mode 100644 index 00000000..2e705863 --- /dev/null +++ b/tests/Idempotency/Storage/RedisStoreTest.php @@ -0,0 +1,104 @@ +connect($host, $port, 0.5); + } catch (\Throwable) { + $connected = false; + } + + if (!$connected) { + self::markTestSkipped(sprintf('Cannot reach Redis at %s:%d; skipping RedisStore tests.', $host, $port)); + } + + // Isolate test data — use a dedicated db when possible. + try { + $redis->select(15); + $redis->flushDB(); + } catch (\Throwable) { + // continue with whatever db; flushDB inside the test prefix will still isolate + } + + $this->redis = $redis; + } + + protected function tearDown(): void + { + if ($this->redis instanceof \Redis) { + try { + $this->redis->flushDB(); + $this->redis->close(); + } catch (\Throwable) { + // best-effort cleanup + } + } + } + + public function testClaimFreshKey(): void + { + \assert($this->redis instanceof Redis); + $store = new RedisStore($this->redis, keyPrefix: 'test.idem.'); + + self::assertNull($store->claim('k1', 'hash', 60)); + } + + public function testConcurrentClaimReturnsInProgressEntry(): void + { + \assert($this->redis instanceof Redis); + $store = new RedisStore($this->redis, keyPrefix: 'test.idem.'); + $store->claim('k1', 'hash', 60); + + $second = $store->claim('k1', 'hash', 60); + + self::assertInstanceOf(StoredResponse::class, $second); + self::assertTrue($second->inProgress); + } + + public function testCompleteThenGet(): void + { + \assert($this->redis instanceof Redis); + $store = new RedisStore($this->redis, keyPrefix: 'test.idem.'); + $store->claim('k1', 'hash', 60); + + $store->complete('k1', StoredResponse::completed('hash', 201, ['Location' => ['/things/1']], '{"id":1}', 0), 60); + + $fetched = $store->get('k1'); + self::assertInstanceOf(StoredResponse::class, $fetched); + self::assertSame(201, $fetched->status); + self::assertSame('{"id":1}', $fetched->body); + self::assertSame(['Location' => ['/things/1']], $fetched->headers); + } + + public function testReleaseDropsClaim(): void + { + \assert($this->redis instanceof Redis); + $store = new RedisStore($this->redis, keyPrefix: 'test.idem.'); + $store->claim('k1', 'hash', 60); + + $store->release('k1'); + + self::assertNull($store->get('k1')); + } +} diff --git a/tests/Idempotency/Storage/StoredResponseTest.php b/tests/Idempotency/Storage/StoredResponseTest.php new file mode 100644 index 00000000..fbd2a563 --- /dev/null +++ b/tests/Idempotency/Storage/StoredResponseTest.php @@ -0,0 +1,134 @@ +requestHash); + self::assertSame(0, $entry->status); + self::assertSame([], $entry->headers); + self::assertSame('', $entry->body); + self::assertTrue($entry->inProgress); + self::assertSame(1_700_000_000, $entry->createdAt); + } + + public function testCompletedFactoryCarriesResponse(): void + { + $entry = StoredResponse::completed( + requestHash: 'hash', + status: 201, + headers: ['Content-Type' => ['application/json']], + body: '{"id":"u_1"}', + createdAt: 1_700_000_100, + ); + + self::assertSame(201, $entry->status); + self::assertSame('{"id":"u_1"}', $entry->body); + self::assertFalse($entry->inProgress); + self::assertSame(['Content-Type' => ['application/json']], $entry->headers); + } + + public function testRoundTripsThroughArray(): void + { + $entry = StoredResponse::completed( + requestHash: 'hash', + status: 200, + headers: ['X-Trace' => ['abc-123']], + body: 'ok', + createdAt: 1_700_000_000, + ); + + $rebuilt = StoredResponse::fromArray($entry->toArray()); + + self::assertSame($entry->requestHash, $rebuilt->requestHash); + self::assertSame($entry->status, $rebuilt->status); + self::assertSame($entry->headers, $rebuilt->headers); + self::assertSame($entry->body, $rebuilt->body); + self::assertSame($entry->inProgress, $rebuilt->inProgress); + self::assertSame($entry->createdAt, $rebuilt->createdAt); + } + + public function testRoundTripsThroughJson(): void + { + $entry = StoredResponse::completed('h', 200, ['Content-Type' => ['text/plain']], 'hi', 42); + + $rebuilt = StoredResponse::fromJson($entry->toJson()); + + self::assertSame($entry->body, $rebuilt->body); + self::assertSame($entry->headers, $rebuilt->headers); + } + + public function testFromArrayRejectsMissingFields(): void + { + $this->expectException(IdempotencyException::class); + $this->expectExceptionMessage('missing required field "body"'); + + StoredResponse::fromArray([ + 'request_hash' => 'h', + 'status' => 200, + 'headers' => [], + // body missing + 'in_progress' => false, + 'created_at' => 0, + ]); + } + + public function testFromArrayRejectsNonArrayHeaders(): void + { + $this->expectException(IdempotencyException::class); + $this->expectExceptionMessage('"headers" must be an array'); + + StoredResponse::fromArray([ + 'request_hash' => 'h', + 'status' => 200, + 'headers' => 'oops', + 'body' => '', + 'in_progress' => false, + 'created_at' => 0, + ]); + } + + public function testFromArraySkipsMalformedHeaderRows(): void + { + $entry = StoredResponse::fromArray([ + 'request_hash' => 'h', + 'status' => 200, + 'headers' => [ + 'Content-Type' => ['application/json'], + 42 => ['skipped because numeric key'], + 'X-Bad' => 'not a list, skipped', + ], + 'body' => '', + 'in_progress' => false, + 'created_at' => 0, + ]); + + self::assertSame(['Content-Type' => ['application/json']], $entry->headers); + } + + public function testFromJsonRejectsMalformedJson(): void + { + $this->expectException(IdempotencyException::class); + $this->expectExceptionMessage('JSON is malformed'); + + StoredResponse::fromJson('{not json'); + } + + public function testFromJsonRejectsNonMap(): void + { + $this->expectException(IdempotencyException::class); + $this->expectExceptionMessage('must decode to a map'); + + StoredResponse::fromJson('"a string"'); + } +}