From f22c2c4756e7d0deedb2ea896b8bd98f50b13984 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 11 Jul 2026 06:17:22 +0200 Subject: [PATCH] feat: Mock Client tracks request objects received in the sendRequest method --- src/Client/Mock.php | 62 ++++++++++++ test/Unit/ClientMockTest.php | 191 +++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 test/Unit/ClientMockTest.php diff --git a/src/Client/Mock.php b/src/Client/Mock.php index d33e201..42a91cd 100644 --- a/src/Client/Mock.php +++ b/src/Client/Mock.php @@ -46,6 +46,17 @@ class Mock implements ClientInterface * @var array */ protected $responses = []; + + /** + * Requests received by sendRequest(), in call order. + * + * Recorded so tests can assert on the effective URI, method and + * headers of what the client under test actually sent. + * + * @var array + */ + protected array $requests = []; + protected ResponseFactoryInterface $responseFactory; protected StreamFactoryInterface $streamFactory; protected Options $options; @@ -123,6 +134,7 @@ public function addResponse( */ public function sendRequest(RequestInterface $request): ResponseInterface { + $this->requests[] = $request; if (empty($this->responses)) { throw new OutOfBoundsException('sendRequest Mock tried to supply a response which was not loaded first'); } @@ -131,4 +143,54 @@ public function sendRequest(RequestInterface $request): ResponseInterface } return $this->responses[0]; } + + /** + * Every request received by sendRequest(), in call order. + * + * Callers use this to assert on the URI, method and headers of + * requests the code under test actually made. The requests are + * the raw PSR-7 objects passed in, unmodified. + * + * @return array + */ + public function getRequests(): array + { + return $this->requests; + } + + /** + * Number of requests received by sendRequest(). + */ + public function getRequestCount(): int + { + return count($this->requests); + } + + /** + * URIs of every request received, in call order, cast to string. + * + * @return array + */ + public function getRequestedUrls(): array + { + return array_map(fn(RequestInterface $r) => (string) $r->getUri(), $this->requests); + } + + /** + * Return the request received at the given zero-based index, or + * null when no request has been made at that index yet. + */ + public function getRequest(int $index = 0): ?RequestInterface + { + return $this->requests[$index] ?? null; + } + + /** + * Discard the recorded request history. Does not touch the + * queued response stack. + */ + public function clearRequests(): void + { + $this->requests = []; + } } diff --git a/test/Unit/ClientMockTest.php b/test/Unit/ClientMockTest.php new file mode 100644 index 0000000..99f3d46 --- /dev/null +++ b/test/Unit/ClientMockTest.php @@ -0,0 +1,191 @@ +createRequest('GET', 'https://example.test/foo'); + + $this->expectException(OutOfBoundsException::class); + $mock->sendRequest($request); + } + + public function testSingleQueuedResponseIsReturnedRepeatedly(): void + { + $mock = new Mock(); + $mock->addResponse('BODY', 200); + + $rf = new RequestFactory(); + $r1 = $mock->sendRequest($rf->createRequest('GET', 'https://example.test/a')); + $r2 = $mock->sendRequest($rf->createRequest('GET', 'https://example.test/b')); + + $this->assertSame(200, $r1->getStatusCode()); + $this->assertSame(200, $r2->getStatusCode()); + } + + public function testMultipleQueuedResponsesAreConsumedInOrder(): void + { + $mock = new Mock(); + $mock->addResponse('FIRST', 200); + $mock->addResponse('SECOND', 200); + $mock->addResponse('THIRD', 200); + + $rf = new RequestFactory(); + $r1 = $mock->sendRequest($rf->createRequest('GET', 'https://example.test/1')); + $r2 = $mock->sendRequest($rf->createRequest('GET', 'https://example.test/2')); + $r3 = $mock->sendRequest($rf->createRequest('GET', 'https://example.test/3')); + + $this->assertSame('FIRST', (string) $r1->getBody()); + $this->assertSame('SECOND', (string) $r2->getBody()); + $this->assertSame('THIRD', (string) $r3->getBody()); + } + + public function testGetRequestsRecordsEveryCall(): void + { + $mock = new Mock(); + $mock->addResponse('BODY', 200); + + $rf = new RequestFactory(); + $req1 = $rf->createRequest('GET', 'https://example.test/foo'); + $req2 = $rf->createRequest('POST', 'https://example.test/bar'); + $mock->sendRequest($req1); + $mock->sendRequest($req2); + + $recorded = $mock->getRequests(); + $this->assertCount(2, $recorded); + $this->assertSame($req1, $recorded[0]); + $this->assertSame($req2, $recorded[1]); + } + + public function testGetRequestCount(): void + { + $mock = new Mock(); + $mock->addResponse('BODY', 200); + + $rf = new RequestFactory(); + $this->assertSame(0, $mock->getRequestCount()); + $mock->sendRequest($rf->createRequest('GET', 'https://example.test/foo')); + $this->assertSame(1, $mock->getRequestCount()); + $mock->sendRequest($rf->createRequest('GET', 'https://example.test/bar')); + $this->assertSame(2, $mock->getRequestCount()); + } + + public function testGetRequestedUrls(): void + { + $mock = new Mock(); + $mock->addResponse('BODY', 200); + + $rf = new RequestFactory(); + $mock->sendRequest($rf->createRequest('GET', 'https://example.test/a?x=1')); + $mock->sendRequest($rf->createRequest('POST', 'https://example.test/b')); + + $this->assertSame( + ['https://example.test/a?x=1', 'https://example.test/b'], + $mock->getRequestedUrls(), + ); + } + + public function testGetRequestByIndex(): void + { + $mock = new Mock(); + $mock->addResponse('BODY', 200); + + $rf = new RequestFactory(); + $req1 = $rf->createRequest('GET', 'https://example.test/foo'); + $req2 = $rf->createRequest('GET', 'https://example.test/bar'); + $mock->sendRequest($req1); + $mock->sendRequest($req2); + + $this->assertSame($req1, $mock->getRequest(0)); + $this->assertSame($req2, $mock->getRequest(1)); + $this->assertNull($mock->getRequest(2)); + $this->assertNull($mock->getRequest(-1)); + } + + public function testRecordingSurvivesThrownOutOfBounds(): void + { + // The recording happens BEFORE the empty-queue check, so a caller + // can still inspect what was requested even when no response was + // queued. This lets tests assert "the code under test made this + // call" separately from "the mock had an answer for it." + $mock = new Mock(); + $rf = new RequestFactory(); + + try { + $mock->sendRequest($rf->createRequest('GET', 'https://example.test/missing')); + $this->fail('expected OutOfBoundsException'); + } catch (OutOfBoundsException) { + // Expected. + } + + $this->assertSame(1, $mock->getRequestCount()); + $this->assertSame('https://example.test/missing', $mock->getRequestedUrls()[0]); + } + + public function testClearRequestsWipesHistoryButKeepsResponseQueue(): void + { + $mock = new Mock(); + $mock->addResponse('BODY', 200); + $rf = new RequestFactory(); + $mock->sendRequest($rf->createRequest('GET', 'https://example.test/foo')); + + $this->assertSame(1, $mock->getRequestCount()); + + $mock->clearRequests(); + $this->assertSame(0, $mock->getRequestCount()); + $this->assertSame([], $mock->getRequests()); + + // The response queue is untouched, so a subsequent call still + // returns the previously-queued response. + $r = $mock->sendRequest($rf->createRequest('GET', 'https://example.test/bar')); + $this->assertSame(200, $r->getStatusCode()); + $this->assertSame(1, $mock->getRequestCount()); + } + + public function testRecordedRequestsPreserveHeaders(): void + { + $mock = new Mock(); + $mock->addResponse('BODY', 200); + + $rf = new RequestFactory(); + $req = $rf->createRequest('GET', 'https://example.test/foo') + ->withHeader('User-Agent', 'my-app/1.0') + ->withHeader('Accept', 'application/json'); + $mock->sendRequest($req); + + $recorded = $mock->getRequest(0); + $this->assertNotNull($recorded); + $this->assertSame('my-app/1.0', $recorded->getHeaderLine('User-Agent')); + $this->assertSame('application/json', $recorded->getHeaderLine('Accept')); + } +}