Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/Client/Mock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, RequestInterface>
*/
protected array $requests = [];

protected ResponseFactoryInterface $responseFactory;
protected StreamFactoryInterface $streamFactory;
protected Options $options;
Expand Down Expand Up @@ -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');
}
Expand All @@ -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<int, RequestInterface>
*/
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<int, string>
*/
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 = [];
}
}
191 changes: 191 additions & 0 deletions test/Unit/ClientMockTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 Horde LLC (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (BSD). If you
* did not receive this file, see http://www.horde.org/licenses/bsd.
*
* @category Horde
* @package Http
* @subpackage UnitTests
* @license http://www.horde.org/licenses/bsd
*/

namespace Horde\Http\Test\Unit;

use Horde\Http\Client\Mock;
use Horde\Http\RequestFactory;
use OutOfBoundsException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

/**
* Unit tests for the modern PSR-18 Mock HTTP client.
*
* Distinct from `MockTest`, which covers the legacy PSR-0
* `Horde_Http_Request_Mock` class.
*/
#[CoversClass(Mock::class)]
class ClientMockTest extends TestCase
{
public function testEmptyQueueThrowsOnSendRequest(): void
{
$mock = new Mock();
$request = (new RequestFactory())->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'));
}
}
Loading