test: add unit tests for HasRetry and HasStreamParsing traits #43

Merged
Snider merged 1 commit from test/service-trait-concerns into main 2026-02-23 06:08:26 +00:00
2 changed files with 821 additions and 0 deletions

View file

@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
/**
* Tests for the HasRetry trait.
*
* Exercises retry logic, exponential backoff, and error classification
* in isolation from any real HTTP provider.
*/
use Core\Mod\Agentic\Services\Concerns\HasRetry;
use GuzzleHttp\Psr7\Response as PsrResponse;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/**
* Build a testable object that uses the HasRetry trait.
*
* sleep() is overridden so tests run without actual delays.
* The recorded sleep durations are accessible via ->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<string,string> $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);
});
});

View file

@ -0,0 +1,433 @@
<?php
declare(strict_types=1);
/**
* Tests for the HasStreamParsing trait.
*
* Exercises SSE (Server-Sent Events) and JSON object stream parsing
* including chunked reads, edge cases, and error handling.
*/
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use Psr\Http\Message\StreamInterface;
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/**
* Create a minimal in-memory PSR-7 stream from a string.
*
* Only eof() and read() are needed by the trait; all other
* StreamInterface methods are stubbed.
*/
function fakeStream(string $data, int $chunkSize = 8192): StreamInterface
{
return new class($data, $chunkSize) implements StreamInterface {
private int $pos = 0;
public function __construct(
private readonly string $data,
private readonly int $chunkSize,
) {}
public function eof(): bool
{
return $this->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']);
});
});