Merge pull request 'test: add unit tests for HasRetry and HasStreamParsing traits' (#43) from test/service-trait-concerns into main
Reviewed-on: #43
This commit is contained in:
commit
143aee7d42
2 changed files with 821 additions and 0 deletions
388
tests/Unit/Concerns/HasRetryTest.php
Normal file
388
tests/Unit/Concerns/HasRetryTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
433
tests/Unit/Concerns/HasStreamParsingTest.php
Normal file
433
tests/Unit/Concerns/HasStreamParsingTest.php
Normal 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue