From 9c50d29c191c1d8356bc705bf38d8bbe20fb708b Mon Sep 17 00:00:00 2001 From: darbs-claude Date: Mon, 23 Feb 2026 01:28:07 +0000 Subject: [PATCH] test: add unit tests for HasRetry and HasStreamParsing traits (#12) - tests/Unit/Concerns/HasRetryTest.php: covers withRetry success paths, max retry limits, non-retryable 4xx errors, exponential backoff with sleep verification, Retry-After header, and calculateDelay formula - tests/Unit/Concerns/HasStreamParsingTest.php: covers parseSSEStream (basic extraction, [DONE] termination, line-type skipping, invalid JSON, chunked reads) and parseJSONStream (single/multiple objects, nesting, escaped strings, extractor filtering, chunked reads) Closes #12 Co-Authored-By: Claude Sonnet 4.6 --- tests/Unit/Concerns/HasRetryTest.php | 388 +++++++++++++++++ tests/Unit/Concerns/HasStreamParsingTest.php | 433 +++++++++++++++++++ 2 files changed, 821 insertions(+) create mode 100644 tests/Unit/Concerns/HasRetryTest.php create mode 100644 tests/Unit/Concerns/HasStreamParsingTest.php diff --git a/tests/Unit/Concerns/HasRetryTest.php b/tests/Unit/Concerns/HasRetryTest.php new file mode 100644 index 0000000..2102aa5 --- /dev/null +++ b/tests/Unit/Concerns/HasRetryTest.php @@ -0,0 +1,388 @@ +sleepCalls. + */ +function retryService(int $maxRetries = 3, int $baseDelayMs = 1000, int $maxDelayMs = 30000): object +{ + return new class($maxRetries, $baseDelayMs, $maxDelayMs) { + use HasRetry; + + public array $sleepCalls = []; + + public function __construct(int $maxRetries, int $baseDelayMs, int $maxDelayMs) + { + $this->maxRetries = $maxRetries; + $this->baseDelayMs = $baseDelayMs; + $this->maxDelayMs = $maxDelayMs; + } + + public function runWithRetry(callable $callback, string $provider): Response + { + return $this->withRetry($callback, $provider); + } + + public function computeDelay(int $attempt, ?Response $response = null): int + { + return $this->calculateDelay($attempt, $response); + } + + protected function sleep(int $milliseconds): void + { + $this->sleepCalls[] = $milliseconds; + } + }; +} + +/** + * Build an Illuminate Response wrapping a real PSR-7 response. + * + * @param array $headers + */ +function fakeHttpResponse(int $status, array $body = [], array $headers = []): Response +{ + return new Response(new PsrResponse($status, $headers, json_encode($body))); +} + +// --------------------------------------------------------------------------- +// withRetry – success paths +// --------------------------------------------------------------------------- + +describe('withRetry success', function () { + it('returns response immediately on first-attempt success', function () { + $service = retryService(); + $response = fakeHttpResponse(200, ['ok' => true]); + + $result = $service->runWithRetry(fn () => $response, 'TestProvider'); + + expect($result->successful())->toBeTrue(); + expect($service->sleepCalls)->toBeEmpty(); + }); + + it('returns response after one transient 429 failure', function () { + $service = retryService(); + $calls = 0; + + $result = $service->runWithRetry(function () use (&$calls) { + $calls++; + + return $calls === 1 + ? fakeHttpResponse(429) + : fakeHttpResponse(200, ['ok' => true]); + }, 'TestProvider'); + + expect($result->successful())->toBeTrue(); + expect($calls)->toBe(2); + }); + + it('returns response after one transient 500 failure', function () { + $service = retryService(); + $calls = 0; + + $result = $service->runWithRetry(function () use (&$calls) { + $calls++; + + return $calls === 1 + ? fakeHttpResponse(500) + : fakeHttpResponse(200, ['ok' => true]); + }, 'TestProvider'); + + expect($result->successful())->toBeTrue(); + expect($calls)->toBe(2); + }); + + it('returns response after one ConnectionException', function () { + $service = retryService(); + $calls = 0; + + $result = $service->runWithRetry(function () use (&$calls) { + $calls++; + if ($calls === 1) { + throw new ConnectionException('Network error'); + } + + return fakeHttpResponse(200, ['ok' => true]); + }, 'TestProvider'); + + expect($result->successful())->toBeTrue(); + expect($calls)->toBe(2); + }); + + it('returns response after one RequestException', function () { + $service = retryService(); + $calls = 0; + + $result = $service->runWithRetry(function () use (&$calls) { + $calls++; + if ($calls === 1) { + throw new RequestException(fakeHttpResponse(503)); + } + + return fakeHttpResponse(200, ['ok' => true]); + }, 'TestProvider'); + + expect($result->successful())->toBeTrue(); + expect($calls)->toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// withRetry – max retry limits +// --------------------------------------------------------------------------- + +describe('withRetry max retry limits', function () { + it('throws after exhausting all retries on persistent 429', function () { + $service = retryService(maxRetries: 3); + $calls = 0; + + expect(function () use ($service, &$calls) { + $service->runWithRetry(function () use (&$calls) { + $calls++; + + return fakeHttpResponse(429); + }, 'TestProvider'); + })->toThrow(RuntimeException::class); + + expect($calls)->toBe(3); + }); + + it('throws after exhausting all retries on persistent 500', function () { + $service = retryService(maxRetries: 3); + $calls = 0; + + expect(function () use ($service, &$calls) { + $service->runWithRetry(function () use (&$calls) { + $calls++; + + return fakeHttpResponse(500); + }, 'TestProvider'); + })->toThrow(RuntimeException::class); + + expect($calls)->toBe(3); + }); + + it('throws after exhausting all retries on persistent ConnectionException', function () { + $service = retryService(maxRetries: 2); + $calls = 0; + + expect(function () use ($service, &$calls) { + $service->runWithRetry(function () use (&$calls) { + $calls++; + throw new ConnectionException('Timeout'); + }, 'TestProvider'); + })->toThrow(RuntimeException::class, 'connection error'); + + expect($calls)->toBe(2); + }); + + it('respects a custom maxRetries of 1 (no retries)', function () { + $service = retryService(maxRetries: 1); + $calls = 0; + + expect(function () use ($service, &$calls) { + $service->runWithRetry(function () use (&$calls) { + $calls++; + + return fakeHttpResponse(500); + }, 'TestProvider'); + })->toThrow(RuntimeException::class); + + expect($calls)->toBe(1); + }); + + it('error message includes provider name', function () { + $service = retryService(maxRetries: 1); + + expect(fn () => $service->runWithRetry(fn () => fakeHttpResponse(500), 'MyProvider')) + ->toThrow(RuntimeException::class, 'MyProvider'); + }); +}); + +// --------------------------------------------------------------------------- +// withRetry – non-retryable errors +// --------------------------------------------------------------------------- + +describe('withRetry non-retryable client errors', function () { + it('throws immediately on 401 without retrying', function () { + $service = retryService(maxRetries: 3); + $calls = 0; + + expect(function () use ($service, &$calls) { + $service->runWithRetry(function () use (&$calls) { + $calls++; + + return fakeHttpResponse(401, ['error' => ['message' => 'Unauthorised']]); + }, 'TestProvider'); + })->toThrow(RuntimeException::class, 'TestProvider API error'); + + expect($calls)->toBe(1); + }); + + it('throws immediately on 400 without retrying', function () { + $service = retryService(maxRetries: 3); + $calls = 0; + + expect(function () use ($service, &$calls) { + $service->runWithRetry(function () use (&$calls) { + $calls++; + + return fakeHttpResponse(400); + }, 'TestProvider'); + })->toThrow(RuntimeException::class); + + expect($calls)->toBe(1); + }); + + it('throws immediately on 404 without retrying', function () { + $service = retryService(maxRetries: 3); + $calls = 0; + + expect(function () use ($service, &$calls) { + $service->runWithRetry(function () use (&$calls) { + $calls++; + + return fakeHttpResponse(404); + }, 'TestProvider'); + })->toThrow(RuntimeException::class); + + expect($calls)->toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// withRetry – sleep (backoff) behaviour +// --------------------------------------------------------------------------- + +describe('withRetry exponential backoff', function () { + it('sleeps between retries but not after the final attempt', function () { + $service = retryService(maxRetries: 3, baseDelayMs: 100, maxDelayMs: 10000); + + try { + $service->runWithRetry(fn () => fakeHttpResponse(500), 'TestProvider'); + } catch (RuntimeException) { + // expected + } + + // 3 attempts → 2 sleeps (between attempt 1-2 and 2-3) + expect($service->sleepCalls)->toHaveCount(2); + }); + + it('does not sleep when succeeding on first attempt', function () { + $service = retryService(); + + $service->runWithRetry(fn () => fakeHttpResponse(200), 'TestProvider'); + + expect($service->sleepCalls)->toBeEmpty(); + }); + + it('sleeps once when succeeding on the second attempt', function () { + $service = retryService(maxRetries: 3, baseDelayMs: 100, maxDelayMs: 10000); + $calls = 0; + + $service->runWithRetry(function () use (&$calls) { + $calls++; + + return $calls === 1 ? fakeHttpResponse(500) : fakeHttpResponse(200); + }, 'TestProvider'); + + expect($service->sleepCalls)->toHaveCount(1); + expect($service->sleepCalls[0])->toBeGreaterThanOrEqual(100); + }); +}); + +// --------------------------------------------------------------------------- +// calculateDelay – exponential backoff formula +// --------------------------------------------------------------------------- + +describe('calculateDelay', function () { + it('returns base delay for attempt 1', function () { + $service = retryService(baseDelayMs: 1000, maxDelayMs: 30000); + + // delay = 1000 * 2^0 = 1000ms, plus up to 25% jitter + $delay = $service->computeDelay(1); + + expect($delay)->toBeGreaterThanOrEqual(1000) + ->and($delay)->toBeLessThanOrEqual(1250); + }); + + it('doubles the delay for attempt 2', function () { + $service = retryService(baseDelayMs: 1000, maxDelayMs: 30000); + + // delay = 1000 * 2^1 = 2000ms, plus up to 25% jitter + $delay = $service->computeDelay(2); + + expect($delay)->toBeGreaterThanOrEqual(2000) + ->and($delay)->toBeLessThanOrEqual(2500); + }); + + it('quadruples the delay for attempt 3', function () { + $service = retryService(baseDelayMs: 1000, maxDelayMs: 30000); + + // delay = 1000 * 2^2 = 4000ms, plus up to 25% jitter + $delay = $service->computeDelay(3); + + expect($delay)->toBeGreaterThanOrEqual(4000) + ->and($delay)->toBeLessThanOrEqual(5000); + }); + + it('caps the delay at maxDelayMs', function () { + $service = retryService(baseDelayMs: 10000, maxDelayMs: 5000); + + // 10000 * 2^0 = 10000ms → capped at 5000ms + $delay = $service->computeDelay(1); + + expect($delay)->toBe(5000); + }); + + it('respects numeric Retry-After header (in seconds)', function () { + $service = retryService(maxDelayMs: 60000); + $response = fakeHttpResponse(429, [], ['Retry-After' => '10']); + + // Retry-After is 10 seconds = 10000ms + $delay = $service->computeDelay(1, $response); + + expect($delay)->toBe(10000); + }); + + it('caps Retry-After header value at maxDelayMs', function () { + $service = retryService(maxDelayMs: 5000); + $response = fakeHttpResponse(429, [], ['Retry-After' => '60']); + + // 60 seconds = 60000ms → capped at 5000ms + $delay = $service->computeDelay(1, $response); + + expect($delay)->toBe(5000); + }); + + it('falls back to exponential backoff when no Retry-After header', function () { + $service = retryService(baseDelayMs: 1000, maxDelayMs: 30000); + $response = fakeHttpResponse(500); + + $delay = $service->computeDelay(1, $response); + + expect($delay)->toBeGreaterThanOrEqual(1000) + ->and($delay)->toBeLessThanOrEqual(1250); + }); +}); diff --git a/tests/Unit/Concerns/HasStreamParsingTest.php b/tests/Unit/Concerns/HasStreamParsingTest.php new file mode 100644 index 0000000..8197863 --- /dev/null +++ b/tests/Unit/Concerns/HasStreamParsingTest.php @@ -0,0 +1,433 @@ +pos >= strlen($this->data); + } + + public function read($length): string + { + $effective = min($length, $this->chunkSize); + $chunk = substr($this->data, $this->pos, $effective); + $this->pos += strlen($chunk); + + return $chunk; + } + + // --- PSR-7 stubs (not exercised by the trait) --- + public function __toString(): string { return $this->data; } + + public function close(): void {} + + public function detach() { return null; } + + public function getSize(): ?int { return strlen($this->data); } + + public function tell(): int { return $this->pos; } + + public function isSeekable(): bool { return false; } + + public function seek($offset, $whence = SEEK_SET): void {} + + public function rewind(): void {} + + public function isWritable(): bool { return false; } + + public function write($string): int { return 0; } + + public function isReadable(): bool { return true; } + + public function getContents(): string { return substr($this->data, $this->pos); } + + public function getMetadata($key = null) { return null; } + }; +} + +/** + * Create a testable object that exposes the HasStreamParsing trait methods. + */ +function streamParsingService(): object +{ + return new class { + use HasStreamParsing; + + public function sse(StreamInterface $stream, callable $extract): Generator + { + return $this->parseSSEStream($stream, $extract); + } + + public function json(StreamInterface $stream, callable $extract): Generator + { + return $this->parseJSONStream($stream, $extract); + } + }; +} + +// --------------------------------------------------------------------------- +// parseSSEStream – basic data extraction +// --------------------------------------------------------------------------- + +describe('parseSSEStream basic parsing', function () { + it('yields content from a single data line', function () { + $raw = "data: {\"text\":\"hello\"}\n\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['hello']); + }); + + it('yields content from multiple data lines', function () { + $raw = "data: {\"text\":\"foo\"}\n"; + $raw .= "data: {\"text\":\"bar\"}\n"; + $raw .= "data: {\"text\":\"baz\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['foo', 'bar', 'baz']); + }); + + it('handles Windows-style \\r\\n line endings', function () { + $raw = "data: {\"text\":\"crlf\"}\r\n\r\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['crlf']); + }); +}); + +// --------------------------------------------------------------------------- +// parseSSEStream – stream termination +// --------------------------------------------------------------------------- + +describe('parseSSEStream stream termination', function () { + it('stops yielding when it encounters [DONE]', function () { + $raw = "data: {\"text\":\"before\"}\n"; + $raw .= "data: [DONE]\n"; + $raw .= "data: {\"text\":\"after\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['before']); + }); + + it('stops when [DONE] has surrounding whitespace', function () { + $raw = "data: {\"text\":\"first\"}\n"; + $raw .= "data: [DONE] \n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['first']); + }); + + it('yields nothing from an empty stream', function () { + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream(''), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBeEmpty(); + }); +}); + +// --------------------------------------------------------------------------- +// parseSSEStream – skipped lines +// --------------------------------------------------------------------------- + +describe('parseSSEStream skipped lines', function () { + it('skips blank/separator lines', function () { + $raw = "\n\ndata: {\"text\":\"ok\"}\n\n\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['ok']); + }); + + it('skips non-data SSE fields (event:, id:, retry:)', function () { + $raw = "event: message\n"; + $raw .= "id: 42\n"; + $raw .= "retry: 3000\n"; + $raw .= "data: {\"text\":\"content\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['content']); + }); + + it('skips SSE comment lines starting with colon', function () { + $raw = ": keep-alive\n"; + $raw .= "data: {\"text\":\"real\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['real']); + }); + + it('skips data lines with empty payload after trimming', function () { + $raw = "data: \n"; + $raw .= "data: {\"text\":\"actual\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['actual']); + }); +}); + +// --------------------------------------------------------------------------- +// parseSSEStream – error handling +// --------------------------------------------------------------------------- + +describe('parseSSEStream error handling', function () { + it('skips lines with invalid JSON', function () { + $raw = "data: not-valid-json\n"; + $raw .= "data: {\"text\":\"valid\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['valid']); + }); + + it('skips lines where extractor returns null', function () { + $raw = "data: {\"other\":\"field\"}\n"; + $raw .= "data: {\"text\":\"present\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['present']); + }); + + it('skips lines where extractor returns empty string', function () { + $raw = "data: {\"text\":\"\"}\n"; + $raw .= "data: {\"text\":\"hello\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['hello']); + }); +}); + +// --------------------------------------------------------------------------- +// parseSSEStream – chunked / partial reads +// --------------------------------------------------------------------------- + +describe('parseSSEStream chunked reads', function () { + it('handles a stream delivered in small chunks', function () { + $raw = "data: {\"text\":\"chunked\"}\n\n"; + $service = streamParsingService(); + + // Force the stream to return 5 bytes at a time + $results = iterator_to_array( + $service->sse(fakeStream($raw, 5), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['chunked']); + }); + + it('processes remaining data buffered after stream EOF', function () { + // No trailing newline – data stays in the buffer until EOF + $raw = "data: {\"text\":\"buffered\"}"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->sse(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['buffered']); + }); +}); + +// --------------------------------------------------------------------------- +// parseJSONStream – basic parsing +// --------------------------------------------------------------------------- + +describe('parseJSONStream basic parsing', function () { + it('yields content from a single JSON object', function () { + $raw = '{"text":"hello"}'; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['hello']); + }); + + it('yields content from multiple consecutive JSON objects', function () { + $raw = '{"text":"first"}{"text":"second"}{"text":"third"}'; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['first', 'second', 'third']); + }); + + it('handles JSON objects separated by whitespace', function () { + $raw = " {\"text\":\"a\"}\n\n {\"text\":\"b\"}\n"; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['a', 'b']); + }); + + it('handles nested JSON objects correctly', function () { + $raw = '{"outer":{"inner":"value"},"text":"top"}'; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['top']); + }); + + it('handles escaped quotes inside strings', function () { + $raw = '{"text":"say \"hello\""}'; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['say "hello"']); + }); +}); + +// --------------------------------------------------------------------------- +// parseJSONStream – extractor filtering +// --------------------------------------------------------------------------- + +describe('parseJSONStream extractor filtering', function () { + it('skips objects where extractor returns null', function () { + $raw = '{"other":"x"}{"text":"keep"}'; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['keep']); + }); + + it('skips objects where extractor returns empty string', function () { + $raw = '{"text":""}{"text":"non-empty"}'; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['non-empty']); + }); + + it('yields nothing from an empty stream', function () { + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream(''), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBeEmpty(); + }); +}); + +// --------------------------------------------------------------------------- +// parseJSONStream – chunked reads +// --------------------------------------------------------------------------- + +describe('parseJSONStream chunked reads', function () { + it('handles objects split across multiple chunks', function () { + $raw = '{"text":"split"}'; + $service = streamParsingService(); + + // Force 3-byte chunks to ensure the object is assembled across reads + $results = iterator_to_array( + $service->json(fakeStream($raw, 3), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['split']); + }); + + it('handles multiple objects across chunks', function () { + $raw = '{"text":"a"}{"text":"b"}'; + $service = streamParsingService(); + + $results = iterator_to_array( + $service->json(fakeStream($raw, 4), fn ($json) => $json['text'] ?? null) + ); + + expect($results)->toBe(['a', 'b']); + }); +}); -- 2.45.3