diff --git a/composer.json b/composer.json index 3d83c44b..fd44f370 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "jms/serializer": "^3.0", "doctrine/annotations": "^2.0", "guzzlehttp/psr7": "^2.0", + "psr/http-client": "^1.0", "deviantintegral/jms-serializer-uri-handler": "^1.1", "deviantintegral/null-date-time": "^1.0", "symfony/console": "^7||^8" diff --git a/src/HarRecorder.php b/src/HarRecorder.php new file mode 100644 index 00000000..ded7978a --- /dev/null +++ b/src/HarRecorder.php @@ -0,0 +1,113 @@ +sendRequest($request); // Makes real request + * $har = $recorder->getHar(); // Get recorded traffic + */ +final class HarRecorder implements ClientInterface +{ + /** + * @var Entry[] + */ + private array $entries = []; + + private Creator $creator; + + /** + * @param ClientInterface $client The underlying HTTP client to delegate requests to + * @param string $creatorName Name of the application creating the HAR + * @param string $creatorVersion Version of the application + */ + public function __construct( + private readonly ClientInterface $client, + string $creatorName = 'deviantintegral/har', + string $creatorVersion = '1.0', + ) { + $this->creator = (new Creator()) + ->setName($creatorName) + ->setVersion($creatorVersion); + } + + /** + * Send an HTTP request and record the request/response pair. + * + * @throws \Psr\Http\Client\ClientExceptionInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $startTime = hrtime(true); + $startDateTime = new \DateTime(); + + $response = $this->client->sendRequest($request); + + $endTime = hrtime(true); + /** @infection-ignore-all Equivalent mutant: 1 part per million difference is not testable */ + $totalTimeMs = ($endTime - $startTime) / 1_000_000; + + $entry = $this->createEntry($request, $response, $startDateTime, $totalTimeMs); + $this->entries[] = $entry; + + return $response; + } + + /** + * Get the recorded traffic as a HAR object. + */ + public function getHar(): Har + { + $log = (new Log()) + ->setVersion('1.2') + ->setCreator($this->creator) + ->setEntries($this->entries); + + return (new Har())->setLog($log); + } + + /** + * Reset and clear all recorded entries. + */ + public function reset(): void + { + $this->entries = []; + } + + /** + * Create a HAR entry from the request/response pair. + */ + private function createEntry( + RequestInterface $request, + ResponseInterface $response, + \DateTime $startDateTime, + float $totalTimeMs, + ): Entry { + $harRequest = Request::fromPsr7Request($request); + $harResponse = Response::fromPsr7Response($response); + + $timings = (new Timings()) + ->setSend(0) + ->setWait($totalTimeMs) + ->setReceive(0); + + return (new Entry()) + ->setStartedDateTime($startDateTime) + ->setTime($totalTimeMs) + ->setRequest($harRequest) + ->setResponse($harResponse) + ->setCache(new Cache()) + ->setTimings($timings); + } +} diff --git a/tests/src/Unit/HarRecorderTest.php b/tests/src/Unit/HarRecorderTest.php new file mode 100644 index 00000000..8654c4bd --- /dev/null +++ b/tests/src/Unit/HarRecorderTest.php @@ -0,0 +1,290 @@ +createMock(ClientInterface::class); + $innerClient->expects($this->once()) + ->method('sendRequest') + ->with($request) + ->willReturn($expectedResponse); + + $recorder = new HarRecorder($innerClient); + $response = $recorder->sendRequest($request); + + $this->assertSame($expectedResponse, $response); + } + + public function testSendRequestRecordsEntry(): void + { + $request = new Request('POST', new Uri('https://example.com/api'), [], 'request body'); + $expectedResponse = new Response(201, ['Content-Type' => 'application/json'], '{"id": 1}'); + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn($expectedResponse); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest($request); + + $entries = $recorder->getHar()->getLog()->getEntries(); + $this->assertCount(1, $entries); + $this->assertInstanceOf(Entry::class, $entries[0]); + } + + public function testRecordedEntryContainsCorrectRequestData(): void + { + $uri = new Uri('https://example.com/api/users'); + $request = new Request('POST', $uri, ['Accept' => 'application/json'], 'test body'); + $response = new Response(200); + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn($response); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest($request); + + $entry = $recorder->getHar()->getLog()->getEntries()[0]; + $harRequest = $entry->getRequest(); + + $this->assertSame('POST', $harRequest->getMethod()); + $this->assertSame((string) $uri, (string) $harRequest->getUrl()); + $this->assertTrue($harRequest->hasPostData()); + $this->assertSame('test body', $harRequest->getPostData()->getText()); + } + + public function testRecordedEntryContainsCorrectResponseData(): void + { + $request = new Request('GET', new Uri('https://example.com')); + $response = new Response( + 200, + ['Content-Type' => 'text/plain'], + 'Hello World' + ); + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn($response); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest($request); + + $entry = $recorder->getHar()->getLog()->getEntries()[0]; + $harResponse = $entry->getResponse(); + + $this->assertSame(200, $harResponse->getStatus()); + $this->assertSame('OK', $harResponse->getStatusText()); + $this->assertSame('Hello World', $harResponse->getContent()->getText()); + } + + public function testRecordedEntryContainsTimingInfo(): void + { + $request = new Request('GET', new Uri('https://example.com')); + $response = new Response(200); + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn($response); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest($request); + + $entry = $recorder->getHar()->getLog()->getEntries()[0]; + + // Time should be non-negative and reasonable (< 1 second for a mocked instant request) + // This catches mutations that would add timestamps instead of subtract, or use wrong divisors + $this->assertGreaterThanOrEqual(0, $entry->getTime()); + $this->assertLessThan(1000, $entry->getTime()); + $this->assertInstanceOf(\DateTimeInterface::class, $entry->getStartedDateTime()); + + $timings = $entry->getTimings(); + $this->assertSame(0.0, $timings->getSend()); + $this->assertGreaterThanOrEqual(0, $timings->getWait()); + $this->assertLessThan(1000, $timings->getWait()); + $this->assertSame(0.0, $timings->getReceive()); + } + + public function testGetHarReturnsValidHarObject(): void + { + $request = new Request('GET', new Uri('https://example.com')); + $response = new Response(200); + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn($response); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest($request); + + $har = $recorder->getHar(); + + $this->assertInstanceOf(Har::class, $har); + $this->assertSame('1.2', $har->getLog()->getVersion()); + $this->assertCount(1, $har->getLog()->getEntries()); + } + + public function testGetHarWithCustomCreator(): void + { + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn(new Response(200)); + + $recorder = new HarRecorder($innerClient, 'MyApp', '2.5.0'); + $recorder->sendRequest(new Request('GET', new Uri('https://example.com'))); + + $har = $recorder->getHar(); + $creator = $har->getLog()->getCreator(); + + $this->assertSame('MyApp', $creator->getName()); + $this->assertSame('2.5.0', $creator->getVersion()); + } + + public function testDefaultCreatorValues(): void + { + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn(new Response(200)); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest(new Request('GET', new Uri('https://example.com'))); + + $har = $recorder->getHar(); + $creator = $har->getLog()->getCreator(); + + $this->assertSame('deviantintegral/har', $creator->getName()); + $this->assertSame('1.0', $creator->getVersion()); + } + + public function testMultipleRequestsAreRecorded(): void + { + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn(new Response(200)); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest(new Request('GET', new Uri('https://example.com/1'))); + $recorder->sendRequest(new Request('POST', new Uri('https://example.com/2'))); + $recorder->sendRequest(new Request('DELETE', new Uri('https://example.com/3'))); + + $entries = $recorder->getHar()->getLog()->getEntries(); + $this->assertCount(3, $entries); + $this->assertCount(3, $recorder->getHar()->getLog()->getEntries()); + + $this->assertSame('GET', $entries[0]->getRequest()->getMethod()); + $this->assertSame('POST', $entries[1]->getRequest()->getMethod()); + $this->assertSame('DELETE', $entries[2]->getRequest()->getMethod()); + } + + public function testRecordedHarIsSerializable(): void + { + $uri = new Uri('https://example.com/api'); + $request = new Request('POST', $uri, ['Content-Type' => 'application/json'], '{"test": true}'); + $response = new Response(201, ['Content-Type' => 'application/json'], '{"id": 123}'); + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn($response); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest($request); + + $har = $recorder->getHar(); + $serializer = $this->getSerializer(); + + $json = $serializer->serialize($har, 'json'); + $this->assertJson($json); + + $data = json_decode($json, true); + $this->assertIsArray($data); + $this->assertArrayHasKey('log', $data); + $this->assertIsArray($data['log']); + $this->assertSame('1.2', $data['log']['version']); + $this->assertArrayHasKey('entries', $data['log']); + $this->assertIsArray($data['log']['entries']); + $this->assertCount(1, $data['log']['entries']); + } + + public function testRecordedEntryHasCacheObject(): void + { + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn(new Response(200)); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest(new Request('GET', new Uri('https://example.com'))); + + $entry = $recorder->getHar()->getLog()->getEntries()[0]; + + $this->assertInstanceOf(\Deviantintegral\Har\Cache::class, $entry->getCache()); + } + + public function testGetHarWithNoEntriesReturnsEmptyHar(): void + { + $innerClient = $this->createMock(ClientInterface::class); + $recorder = new HarRecorder($innerClient); + + $har = $recorder->getHar(); + + $this->assertInstanceOf(Har::class, $har); + $this->assertSame('1.2', $har->getLog()->getVersion()); + $this->assertEmpty($har->getLog()->getEntries()); + } + + public function testClientExceptionIsPropagated(): void + { + $exception = new class extends \Exception implements \Psr\Http\Client\ClientExceptionInterface {}; + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest') + ->willThrowException($exception); + + $recorder = new HarRecorder($innerClient); + + $this->expectException(\Psr\Http\Client\ClientExceptionInterface::class); + $recorder->sendRequest(new Request('GET', new Uri('https://example.com'))); + } + + public function testFailedRequestIsNotRecorded(): void + { + $exception = new class extends \Exception implements \Psr\Http\Client\ClientExceptionInterface {}; + + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest') + ->willThrowException($exception); + + $recorder = new HarRecorder($innerClient); + + try { + $recorder->sendRequest(new Request('GET', new Uri('https://example.com'))); + } catch (\Psr\Http\Client\ClientExceptionInterface) { + // Expected + } + + $this->assertEmpty($recorder->getHar()->getLog()->getEntries()); + } + + public function testResetRemovesAllEntries(): void + { + $innerClient = $this->createMock(ClientInterface::class); + $innerClient->method('sendRequest')->willReturn(new Response(200)); + + $recorder = new HarRecorder($innerClient); + $recorder->sendRequest(new Request('GET', new Uri('https://example.com/1'))); + $recorder->sendRequest(new Request('GET', new Uri('https://example.com/2'))); + + $this->assertCount(2, $recorder->getHar()->getLog()->getEntries()); + + $recorder->reset(); + + $this->assertEmpty($recorder->getHar()->getLog()->getEntries()); + } +}