diff --git a/examples/chat-gpt-openai-metadata.php b/examples/chat-gpt-openai-metadata.php new file mode 100644 index 00000000..2901104e --- /dev/null +++ b/examples/chat-gpt-openai-metadata.php @@ -0,0 +1,38 @@ +loadEnv(dirname(__DIR__).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$llm = new GPT(GPT::GPT_4O_MINI, [ + 'temperature' => 0.5, // default options for the model +]); + +$chain = new Chain($platform, $llm, outputProcessors: [new TokenOutputProcessor()]); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $chain->call($messages, [ + 'max_tokens' => 500, // specific options just for this call +]); + +$metadata = $response->getMetadata(); + +echo 'Utilized Tokens: '.$metadata['total_tokens'].PHP_EOL; +echo '-- Prompt Tokens: '.$metadata['prompt_tokens'].PHP_EOL; +echo '-- Completion Tokens: '.$metadata['completion_tokens'].PHP_EOL; +echo 'Remaining Tokens: '.$metadata['remaining_tokens'].PHP_EOL; diff --git a/src/Bridge/OpenAI/DallE/ImageResponse.php b/src/Bridge/OpenAI/DallE/ImageResponse.php index 58c8ffc1..eabece9d 100644 --- a/src/Bridge/OpenAI/DallE/ImageResponse.php +++ b/src/Bridge/OpenAI/DallE/ImageResponse.php @@ -4,9 +4,9 @@ namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; +use PhpLlm\LlmChain\Model\Response\BaseResponse; -class ImageResponse implements ResponseInterface +class ImageResponse extends BaseResponse { /** @var list */ private readonly array $images; diff --git a/src/Bridge/OpenAI/TokenOutputProcessor.php b/src/Bridge/OpenAI/TokenOutputProcessor.php new file mode 100644 index 00000000..69089b5b --- /dev/null +++ b/src/Bridge/OpenAI/TokenOutputProcessor.php @@ -0,0 +1,42 @@ +response instanceof StreamResponse) { + // Streams have to be handled manually as the tokens are part of the streamed chunks + return; + } + + $rawResponse = $output->response->getRawResponse(); + if (null === $rawResponse) { + return; + } + + $metadata = $output->response->getMetadata(); + + $metadata->add( + 'remaining_tokens', + (int) $rawResponse->getHeaders(false)['x-ratelimit-remaining-tokens'][0], + ); + + $content = $rawResponse->toArray(false); + + if (!\array_key_exists('usage', $content)) { + return; + } + + $metadata->add('prompt_tokens', $content['usage']['prompt_tokens'] ?? null); + $metadata->add('completion_tokens', $content['usage']['completion_tokens'] ?? null); + $metadata->add('total_tokens', $content['usage']['total_tokens'] ?? null); + } +} diff --git a/src/Chain/Toolbox/StreamResponse.php b/src/Chain/Toolbox/StreamResponse.php index c58e6eaa..190c837b 100644 --- a/src/Chain/Toolbox/StreamResponse.php +++ b/src/Chain/Toolbox/StreamResponse.php @@ -5,14 +5,14 @@ namespace PhpLlm\LlmChain\Chain\Toolbox; use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; +use PhpLlm\LlmChain\Model\Response\BaseResponse; use PhpLlm\LlmChain\Model\Response\ToolCallResponse; -final readonly class StreamResponse implements ResponseInterface +final class StreamResponse extends BaseResponse { public function __construct( - private \Generator $generator, - private \Closure $handleToolCallsCallback, + private readonly \Generator $generator, + private readonly \Closure $handleToolCallsCallback, ) { } diff --git a/src/Model/Response/AsyncResponse.php b/src/Model/Response/AsyncResponse.php index b070a1cb..6eff7fb8 100644 --- a/src/Model/Response/AsyncResponse.php +++ b/src/Model/Response/AsyncResponse.php @@ -4,11 +4,15 @@ namespace PhpLlm\LlmChain\Model\Response; +use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; +use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait; use PhpLlm\LlmChain\Platform\ResponseConverter; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; final class AsyncResponse implements ResponseInterface { + use MetadataAwareTrait; + private bool $isConverted = false; private ResponseInterface $convertedResponse; @@ -27,10 +31,27 @@ public function getContent(): string|iterable|object|null return $this->unwrap()->getContent(); } + public function getRawResponse(): HttpResponse + { + return $this->response; + } + + public function setRawResponse(HttpResponse $rawResponse): void + { + // Empty by design as the raw response is already set in the constructor and must only be set once + throw new RawResponseAlreadySet(); + } + public function unwrap(): ResponseInterface { if (!$this->isConverted) { $this->convertedResponse = $this->responseConverter->convert($this->response, $this->options); + + if (null === $this->convertedResponse->getRawResponse()) { + // Fallback to set the raw response when it was not handled by the response converter itself + $this->convertedResponse->setRawResponse($this->response); + } + $this->isConverted = true; } diff --git a/src/Model/Response/BaseResponse.php b/src/Model/Response/BaseResponse.php new file mode 100644 index 00000000..b8848d2f --- /dev/null +++ b/src/Model/Response/BaseResponse.php @@ -0,0 +1,13 @@ + + * @implements \ArrayAccess + */ +class Metadata implements \JsonSerializable, \Countable, \IteratorAggregate, \ArrayAccess +{ + /** + * @var array + */ + private array $metadata = []; + + /** + * @param array $metadata + */ + public function __construct(array $metadata = []) + { + $this->set($metadata); + } + + /** + * @return array + */ + public function all(): array + { + return $this->metadata; + } + + /** + * @param array $metadata + */ + public function set(array $metadata): void + { + $this->metadata = $metadata; + } + + public function add(string $key, mixed $value): void + { + $this->metadata[$key] = $value; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->metadata); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->metadata[$key] ?? $default; + } + + public function remove(string $key): void + { + unset($this->metadata[$key]); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->all(); + } + + public function count(): int + { + return \count($this->metadata); + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->metadata); + } + + public function offsetExists(mixed $offset): bool + { + return $this->has((string) $offset); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->get((string) $offset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->add((string) $offset, $value); + } + + public function offsetUnset(mixed $offset): void + { + $this->remove((string) $offset); + } +} diff --git a/src/Model/Response/Metadata/MetadataAwareTrait.php b/src/Model/Response/Metadata/MetadataAwareTrait.php new file mode 100644 index 00000000..eac0f88c --- /dev/null +++ b/src/Model/Response/Metadata/MetadataAwareTrait.php @@ -0,0 +1,15 @@ +metadata ??= new Metadata(); + } +} diff --git a/src/Model/Response/RawResponseAwareTrait.php b/src/Model/Response/RawResponseAwareTrait.php new file mode 100644 index 00000000..29578dd3 --- /dev/null +++ b/src/Model/Response/RawResponseAwareTrait.php @@ -0,0 +1,27 @@ +rawResponse) { + throw new RawResponseAlreadySet(); + } + + $this->rawResponse = $rawResponse; + } + + public function getRawResponse(): ?SymfonyHttpResponse + { + return $this->rawResponse; + } +} diff --git a/src/Model/Response/ResponseInterface.php b/src/Model/Response/ResponseInterface.php index 8b799bc6..a1651b79 100644 --- a/src/Model/Response/ResponseInterface.php +++ b/src/Model/Response/ResponseInterface.php @@ -4,10 +4,23 @@ namespace PhpLlm\LlmChain\Model\Response; +use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; +use PhpLlm\LlmChain\Model\Response\Metadata\Metadata; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; + interface ResponseInterface { /** * @return string|iterable|object|null */ public function getContent(): string|iterable|object|null; + + public function getMetadata(): Metadata; + + public function getRawResponse(): ?SymfonyHttpResponse; + + /** + * @throws RawResponseAlreadySet if the response is tried to be set more than once + */ + public function setRawResponse(SymfonyHttpResponse $rawResponse): void; } diff --git a/src/Model/Response/StreamResponse.php b/src/Model/Response/StreamResponse.php index b9618f54..da45ef23 100644 --- a/src/Model/Response/StreamResponse.php +++ b/src/Model/Response/StreamResponse.php @@ -4,10 +4,10 @@ namespace PhpLlm\LlmChain\Model\Response; -final readonly class StreamResponse implements ResponseInterface +final class StreamResponse extends BaseResponse { public function __construct( - private \Generator $generator, + private readonly \Generator $generator, ) { } diff --git a/src/Model/Response/StructuredResponse.php b/src/Model/Response/StructuredResponse.php index 25918ca7..96c53123 100644 --- a/src/Model/Response/StructuredResponse.php +++ b/src/Model/Response/StructuredResponse.php @@ -4,13 +4,13 @@ namespace PhpLlm\LlmChain\Model\Response; -final readonly class StructuredResponse implements ResponseInterface +final class StructuredResponse extends BaseResponse { /** * @param object|array $structuredOutput */ public function __construct( - private object|array $structuredOutput, + private readonly object|array $structuredOutput, ) { } diff --git a/src/Model/Response/TextResponse.php b/src/Model/Response/TextResponse.php index 1c289e31..09c37de7 100644 --- a/src/Model/Response/TextResponse.php +++ b/src/Model/Response/TextResponse.php @@ -4,10 +4,10 @@ namespace PhpLlm\LlmChain\Model\Response; -final readonly class TextResponse implements ResponseInterface +final class TextResponse extends BaseResponse { public function __construct( - private string $content, + private readonly string $content, ) { } diff --git a/src/Model/Response/ToolCallResponse.php b/src/Model/Response/ToolCallResponse.php index 40bff942..0f4530d0 100644 --- a/src/Model/Response/ToolCallResponse.php +++ b/src/Model/Response/ToolCallResponse.php @@ -6,12 +6,12 @@ use PhpLlm\LlmChain\Exception\InvalidArgumentException; -final readonly class ToolCallResponse implements ResponseInterface +final class ToolCallResponse extends BaseResponse { /** * @var ToolCall[] */ - private array $toolCalls; + private readonly array $toolCalls; public function __construct(ToolCall ...$toolCalls) { diff --git a/src/Model/Response/VectorResponse.php b/src/Model/Response/VectorResponse.php index f14201eb..9e54a704 100644 --- a/src/Model/Response/VectorResponse.php +++ b/src/Model/Response/VectorResponse.php @@ -6,12 +6,12 @@ use PhpLlm\LlmChain\Document\Vector; -final readonly class VectorResponse implements ResponseInterface +final class VectorResponse extends BaseResponse { /** * @var Vector[] */ - private array $vectors; + private readonly array $vectors; public function __construct(Vector ...$vector) { diff --git a/tests/Bridge/OpenAI/TokenOutputProcessorTest.php b/tests/Bridge/OpenAI/TokenOutputProcessorTest.php new file mode 100644 index 00000000..eb4d864e --- /dev/null +++ b/tests/Bridge/OpenAI/TokenOutputProcessorTest.php @@ -0,0 +1,152 @@ +createOutput($streamResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(0, $metadata); + } + + #[Test] + public function itDoesNothingWithoutRawResponse(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(0, $metadata); + } + + #[Test] + public function itAddsRemainingTokensToMetadata(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + + $rawResponse = self::createStub(SymfonyHttpResponse::class); + $rawResponse->method('getHeaders')->willReturn([ + 'x-ratelimit-remaining-tokens' => ['1000'], + ]); + $rawResponse->method('toArray')->willReturn([]); + + $textResponse->setRawResponse($rawResponse); + + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(1, $metadata); + self::assertSame(1000, $metadata->get('remaining_tokens')); + } + + #[Test] + public function itAddsUsageTokensToMetadata(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + + $rawResponse = self::createStub(SymfonyHttpResponse::class); + $rawResponse->method('getHeaders')->willReturn([ + 'x-ratelimit-remaining-tokens' => ['1000'], + ]); + $rawResponse->method('toArray')->willReturn([ + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 20, + 'total_tokens' => 30, + ], + ]); + + $textResponse->setRawResponse($rawResponse); + + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(4, $metadata); + self::assertSame(1000, $metadata->get('remaining_tokens')); + self::assertSame(10, $metadata->get('prompt_tokens')); + self::assertSame(20, $metadata->get('completion_tokens')); + self::assertSame(30, $metadata->get('total_tokens')); + } + + #[Test] + public function itHandlesMissingUsageFields(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + + $rawResponse = self::createStub(SymfonyHttpResponse::class); + $rawResponse->method('getHeaders')->willReturn([ + 'x-ratelimit-remaining-tokens' => ['1000'], + ]); + $rawResponse->method('toArray')->willReturn([ + 'usage' => [ + // Missing some fields + 'prompt_tokens' => 10, + ], + ]); + + $textResponse->setRawResponse($rawResponse); + + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(4, $metadata); + self::assertSame(1000, $metadata->get('remaining_tokens')); + self::assertSame(10, $metadata->get('prompt_tokens')); + self::assertNull($metadata->get('completion_tokens')); + self::assertNull($metadata->get('total_tokens')); + } + + private function createOutput(ResponseInterface $response): Output + { + return new Output( + self::createStub(LanguageModel::class), + $response, + self::createStub(MessageBagInterface::class), + [], + ); + } +} diff --git a/tests/Model/Response/AsyncResponseTest.php b/tests/Model/Response/AsyncResponseTest.php new file mode 100644 index 00000000..71143382 --- /dev/null +++ b/tests/Model/Response/AsyncResponseTest.php @@ -0,0 +1,162 @@ +createStub(SymfonyHttpResponse::class); + $textResponse = new TextResponse('test content'); + + $responseConverter = $this->createMock(ResponseConverter::class); + $responseConverter->expects($this->once()) + ->method('convert') + ->with($httpResponse, []) + ->willReturn($textResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + + self::assertSame('test content', $asyncResponse->getContent()); + } + + #[Test] + public function itConvertsTheResponseOnlyOnce(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $textResponse = new TextResponse('test content'); + + $responseConverter = $this->createMock(ResponseConverter::class); + $responseConverter->expects($this->once()) + ->method('convert') + ->with($httpResponse, []) + ->willReturn($textResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + + // Call unwrap multiple times, but the converter should only be called once + $asyncResponse->unwrap(); + $asyncResponse->unwrap(); + $asyncResponse->getContent(); + } + + #[Test] + public function itGetsRawResponseDirectly(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $responseConverter = $this->createStub(ResponseConverter::class); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + + self::assertSame($httpResponse, $asyncResponse->getRawResponse()); + } + + #[Test] + public function itThrowsExceptionWhenSettingRawResponse(): void + { + $this->expectException(RawResponseAlreadySet::class); + + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $responseConverter = $this->createStub(ResponseConverter::class); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + $asyncResponse->setRawResponse($httpResponse); + } + + #[Test] + public function itSetsRawResponseOnUnwrappedResponseWhenNeeded(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + + $unwrappedResponse = $this->createResponse(null); + + $responseConverter = $this->createStub(ResponseConverter::class); + $responseConverter->method('convert')->willReturn($unwrappedResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + $asyncResponse->unwrap(); + + // The raw response in the model response is now set and not null anymore + self::assertSame($httpResponse, $unwrappedResponse->getRawResponse()); + } + + #[Test] + public function itDoesNotSetRawResponseOnUnwrappedResponseWhenAlreadySet(): void + { + $originHttpResponse = $this->createStub(SymfonyHttpResponse::class); + $anotherHttpResponse = $this->createStub(SymfonyHttpResponse::class); + + $unwrappedResponse = $this->createResponse($anotherHttpResponse); + + $responseConverter = $this->createStub(ResponseConverter::class); + $responseConverter->method('convert')->willReturn($unwrappedResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $originHttpResponse); + $asyncResponse->unwrap(); + + // It is still the same raw response as set initially and so not overwritten + self::assertSame($anotherHttpResponse, $unwrappedResponse->getRawResponse()); + } + + /** + * Workaround for low deps because mocking the ResponseInterface leads to an exception with + * mock creation "Type Traversable|object|array|string|null contains both object and a class type" + * in PHPUnit MockClass. + */ + private function createResponse(?SymfonyHttpResponse $rawResponse): ResponseInterface + { + return new class($rawResponse) extends BaseResponse { + public function __construct(protected ?SymfonyHttpResponse $rawResponse) + { + } + + public function getContent(): string + { + return 'test content'; + } + + public function getRawResponse(): ?SymfonyHttpResponse + { + return $this->rawResponse; + } + }; + } + + #[Test] + public function itPassesOptionsToConverter(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $options = ['option1' => 'value1', 'option2' => 'value2']; + + $responseConverter = $this->createMock(ResponseConverter::class); + $responseConverter->expects($this->once()) + ->method('convert') + ->with($httpResponse, $options) + ->willReturn($this->createResponse(null)); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse, $options); + $asyncResponse->unwrap(); + } +} diff --git a/tests/Model/Response/BaseResponseTest.php b/tests/Model/Response/BaseResponseTest.php new file mode 100644 index 00000000..2988d694 --- /dev/null +++ b/tests/Model/Response/BaseResponseTest.php @@ -0,0 +1,69 @@ +createResponse(); + $metadata = $response->getMetadata(); + + self::assertCount(0, $metadata); + + $metadata->add('key', 'value'); + $metadata = $response->getMetadata(); + + self::assertCount(1, $metadata); + } + + #[Test] + public function itCanBeEnrichedWithARawResponse(): void + { + $response = $this->createResponse(); + $rawResponse = $this->createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + self::assertSame($rawResponse, $response->getRawResponse()); + } + + #[Test] + public function itThrowsAnExceptionWhenSettingARawResponseTwice(): void + { + $this->expectException(RawResponseAlreadySet::class); + + $response = $this->createResponse(); + $rawResponse = $this->createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + $response->setRawResponse($rawResponse); + } + + private function createResponse(): BaseResponse + { + return new class extends BaseResponse { + public function getContent(): string + { + return 'test'; + } + }; + } +} diff --git a/tests/Model/Response/Exception/RawResponseAlreadySetTest.php b/tests/Model/Response/Exception/RawResponseAlreadySetTest.php new file mode 100644 index 00000000..42d35744 --- /dev/null +++ b/tests/Model/Response/Exception/RawResponseAlreadySetTest.php @@ -0,0 +1,24 @@ +getMessage()); + } +} diff --git a/tests/Model/Response/Metadata/MetadataAwareTraitTest.php b/tests/Model/Response/Metadata/MetadataAwareTraitTest.php new file mode 100644 index 00000000..c1f5543f --- /dev/null +++ b/tests/Model/Response/Metadata/MetadataAwareTraitTest.php @@ -0,0 +1,37 @@ +createTestClass(); + $metadata = $response->getMetadata(); + + self::assertCount(0, $metadata); + + $metadata->add('key', 'value'); + $metadata = $response->getMetadata(); + + self::assertCount(1, $metadata); + } + + private function createTestClass(): object + { + return new class { + use MetadataAwareTrait; + }; + } +} diff --git a/tests/Model/Response/Metadata/MetadataTest.php b/tests/Model/Response/Metadata/MetadataTest.php new file mode 100644 index 00000000..70081482 --- /dev/null +++ b/tests/Model/Response/Metadata/MetadataTest.php @@ -0,0 +1,130 @@ +all()); + } + + #[Test] + public function itCanBeCreatedWithInitialData(): void + { + $metadata = new Metadata(['key' => 'value']); + self::assertCount(1, $metadata); + self::assertSame(['key' => 'value'], $metadata->all()); + } + + #[Test] + public function itCanAddNewMetadata(): void + { + $metadata = new Metadata(); + $metadata->add('key', 'value'); + + self::assertTrue($metadata->has('key')); + self::assertSame('value', $metadata->get('key')); + } + + #[Test] + public function itCanCheckIfMetadataExists(): void + { + $metadata = new Metadata(['key' => 'value']); + + self::assertTrue($metadata->has('key')); + self::assertFalse($metadata->has('nonexistent')); + } + + #[Test] + public function itCanGetMetadataWithDefault(): void + { + $metadata = new Metadata(['key' => 'value']); + + self::assertSame('value', $metadata->get('key')); + self::assertSame('default', $metadata->get('nonexistent', 'default')); + self::assertNull($metadata->get('nonexistent')); + } + + #[Test] + public function itCanRemoveMetadata(): void + { + $metadata = new Metadata(['key' => 'value']); + self::assertTrue($metadata->has('key')); + + $metadata->remove('key'); + self::assertFalse($metadata->has('key')); + } + + #[Test] + public function itCanSetEntireMetadataArray(): void + { + $metadata = new Metadata(['key1' => 'value1']); + $metadata->set(['key2' => 'value2', 'key3' => 'value3']); + + self::assertFalse($metadata->has('key1')); + self::assertTrue($metadata->has('key2')); + self::assertTrue($metadata->has('key3')); + self::assertSame(['key2' => 'value2', 'key3' => 'value3'], $metadata->all()); + } + + #[Test] + public function itImplementsJsonSerializable(): void + { + $metadata = new Metadata(['key' => 'value']); + self::assertSame(['key' => 'value'], $metadata->jsonSerialize()); + } + + #[Test] + public function itImplementsArrayAccess(): void + { + $metadata = new Metadata(['key' => 'value']); + + self::assertArrayHasKey('key', $metadata); + self::assertSame('value', $metadata['key']); + + $metadata['new'] = 'newValue'; + self::assertSame('newValue', $metadata['new']); + + unset($metadata['key']); + self::assertArrayNotHasKey('key', $metadata); + } + + #[Test] + public function itImplementsIteratorAggregate(): void + { + $metadata = new Metadata(['key1' => 'value1', 'key2' => 'value2']); + $result = \iterator_to_array($metadata); + + self::assertSame(['key1' => 'value1', 'key2' => 'value2'], $result); + } + + #[Test] + public function itImplementsCountable(): void + { + $metadata = new Metadata(); + self::assertCount(0, $metadata); + + $metadata->add('key', 'value'); + self::assertCount(1, $metadata); + + $metadata->add('key2', 'value2'); + self::assertCount(2, $metadata); + + $metadata->remove('key'); + self::assertCount(1, $metadata); + } +} diff --git a/tests/Model/Response/RawResponseAwareTraitTest.php b/tests/Model/Response/RawResponseAwareTraitTest.php new file mode 100644 index 00000000..1ad29208 --- /dev/null +++ b/tests/Model/Response/RawResponseAwareTraitTest.php @@ -0,0 +1,47 @@ +createTestClass(); + $rawResponse = $this->createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + self::assertSame($rawResponse, $response->getRawResponse()); + } + + #[Test] + public function itThrowsAnExceptionWhenSettingARawResponseTwice(): void + { + $this->expectException(RawResponseAlreadySet::class); + + $response = $this->createTestClass(); + $rawResponse = $this->createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + $response->setRawResponse($rawResponse); + } + + private function createTestClass(): object + { + return new class { + use RawResponseAwareTrait; + }; + } +}