From 21f40f3bd830d8ac827974b1b7218e969892322d Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Mon, 27 Oct 2025 16:47:58 +0100 Subject: [PATCH] feat(chat): SurrealDb message store --- docs/components/chat.rst | 4 + examples/chat/persistent-chat-surrealdb.php | 45 ++++ examples/commands/message-stores.php | 10 + src/ai-bundle/config/options.php | 16 ++ src/ai-bundle/src/AiBundle.php | 31 +++ .../DependencyInjection/AiBundleTest.php | 136 +++++++++++ src/chat/CHANGELOG.md | 1 + .../src/Bridge/SurrealDb/MessageStore.php | 145 ++++++++++++ .../Bridge/SurrealDb/MessageStoreTest.php | 212 ++++++++++++++++++ 9 files changed, 600 insertions(+) create mode 100644 examples/chat/persistent-chat-surrealdb.php create mode 100644 src/chat/src/Bridge/SurrealDb/MessageStore.php create mode 100644 src/chat/tests/Bridge/SurrealDb/MessageStoreTest.php diff --git a/docs/components/chat.rst b/docs/components/chat.rst index d513a465f..7a2c6edb3 100644 --- a/docs/components/chat.rst +++ b/docs/components/chat.rst @@ -39,6 +39,7 @@ You can find more advanced usage in combination with an Agent using the store fo * `Long-term context with Meilisearch`_ * `Long-term context with Pogocache`_ * `Long-term context with Redis`_ +* `Long-term context with SurrealDb`_ Supported Message stores ------------------------ @@ -49,6 +50,7 @@ Supported Message stores * `Meilisearch`_ * `Pogocache`_ * `Redis`_ +* `SurrealDb`_ Implementing a Bridge --------------------- @@ -130,9 +132,11 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store: .. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php .. _`Long-term context with Pogocache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-pogocache.php .. _`Long-term context with Redis`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-redis.php +.. _`Long-term context with SurrealDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-surrealdb.php .. _`Cache`: https://symfony.com/doc/current/components/cache.html .. _`InMemory`: https://www.php.net/manual/en/language.types.array.php .. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/ .. _`Meilisearch`: https://www.meilisearch.com/ .. _`Pogocache`: https://pogocache.com/ .. _`Redis`: https://redis.io/ +.. _`SurrealDb`: https://surrealdb.com/ diff --git a/examples/chat/persistent-chat-surrealdb.php b/examples/chat/persistent-chat-surrealdb.php new file mode 100644 index 000000000..8aec4fc3f --- /dev/null +++ b/examples/chat/persistent-chat-surrealdb.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Chat\Bridge\SurrealDb\MessageStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +// SurrealDb does not require to call the `setup()` method as the table is created during insertion +$store = new MessageStore( + httpClient: http_client(), + endpointUrl: 'http://127.0.0.1:8000', + user: env('SURREALDB_USER'), + password: env('SURREALDB_PASS'), + namespace: 'default', + database: 'chat', + table: 'chat', +); + +$agent = new Agent($platform, 'gpt-4o-mini'); +$chat = new Chat($agent, $store); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +); + +$chat->initiate($messages); +$chat->submit(Message::ofUser('My name is Christopher.')); +$message = $chat->submit(Message::ofUser('What is my name?')); + +echo $message->getContent().\PHP_EOL; diff --git a/examples/commands/message-stores.php b/examples/commands/message-stores.php index 29ea9038a..c9fce7f2b 100644 --- a/examples/commands/message-stores.php +++ b/examples/commands/message-stores.php @@ -17,6 +17,7 @@ use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore; use Symfony\AI\Chat\Bridge\Pogocache\MessageStore as PogocacheMessageStore; use Symfony\AI\Chat\Bridge\Redis\MessageStore as RedisMessageStore; +use Symfony\AI\Chat\Bridge\SurrealDb\MessageStore as SurrealDbMessageStore; use Symfony\AI\Chat\Command\DropStoreCommand; use Symfony\AI\Chat\Command\SetupStoreCommand; use Symfony\AI\Chat\MessageNormalizer; @@ -68,6 +69,15 @@ return new SessionStore($requestStack, 'symfony'); }, + 'surrealdb' => static fn (): SurrealDbMessageStore => new SurrealDbMessageStore( + httpClient: http_client(), + endpointUrl: env('SURREALDB_HOST'), + user: env('SURREALDB_USER'), + password: env('SURREALDB_PASS'), + namespace: 'default', + database: 'chat', + table: 'chat', + ), ]; $storesIds = array_keys($factories); diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 647d34c21..27926136b 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -847,6 +847,22 @@ ->end() ->end() ->end() + ->arrayNode('surreal_db') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('endpoint')->cannotBeEmpty()->end() + ->stringNode('username')->cannotBeEmpty()->end() + ->stringNode('password')->cannotBeEmpty()->end() + ->stringNode('namespace')->cannotBeEmpty()->end() + ->stringNode('database')->cannotBeEmpty()->end() + ->stringNode('table')->end() + ->booleanNode('namespaced_user') + ->info('Using a namespaced user is a good practice to prevent any undesired access to a specific table, see https://surrealdb.com/docs/surrealdb/reference-guide/security-best-practices') + ->end() + ->end() + ->end() + ->end() ->end() ->end() ->arrayNode('chat') diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index f605fc7ce..f98231024 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -41,6 +41,7 @@ use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore; use Symfony\AI\Chat\Bridge\Pogocache\MessageStore as PogocacheMessageStore; use Symfony\AI\Chat\Bridge\Redis\MessageStore as RedisMessageStore; +use Symfony\AI\Chat\Bridge\SurrealDb\MessageStore as SurrealDbMessageStore; use Symfony\AI\Chat\Chat; use Symfony\AI\Chat\ChatInterface; use Symfony\AI\Chat\MessageStoreInterface; @@ -1596,6 +1597,36 @@ private function processMessageStoreConfig(string $type, array $messageStores, C $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name); } } + + if ('surreal_db' === $type) { + foreach ($messageStores as $name => $messageStore) { + $arguments = [ + new Reference('http_client'), + $messageStore['endpoint'], + $messageStore['username'], + $messageStore['password'], + $messageStore['namespace'], + $messageStore['database'], + new Reference('serializer'), + $messageStore['table'] ?? $name, + ]; + + if (\array_key_exists('namespaced_user', $messageStore)) { + $arguments[8] = $messageStore['namespaced_user']; + } + + $definition = new Definition(SurrealDbMessageStore::class); + $definition + ->setLazy(true) + ->addTag('proxy', ['interface' => MessageStoreInterface::class]) + ->addTag('ai.message_store') + ->setArguments($arguments); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, StoreInterface::class, $name); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, StoreInterface::class, $type.'_'.$name); + } + } } /** diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 9558a4b02..2bfd4ec96 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -3080,6 +3080,123 @@ public function testSessionMessageStoreIsConfigured() $this->assertTrue($sessionMessageStoreDefinition->hasTag('ai.message_store')); } + public function testSurrealDbMessageStoreIsConfiguredWithoutCustomTable() + { + $container = $this->buildContainer([ + 'ai' => [ + 'message_store' => [ + 'surreal_db' => [ + 'custom' => [ + 'endpoint' => 'http://127.0.0.1:8000', + 'username' => 'test', + 'password' => 'test', + 'namespace' => 'foo', + 'database' => 'bar', + ], + ], + ], + ], + ]); + + $surrealDbMessageStoreDefinition = $container->getDefinition('ai.message_store.surreal_db.custom'); + + $this->assertTrue($surrealDbMessageStoreDefinition->isLazy()); + $this->assertCount(8, $surrealDbMessageStoreDefinition->getArguments()); + $this->assertInstanceOf(Reference::class, $surrealDbMessageStoreDefinition->getArgument(0)); + $this->assertSame('http_client', (string) $surrealDbMessageStoreDefinition->getArgument(0)); + $this->assertSame('http://127.0.0.1:8000', (string) $surrealDbMessageStoreDefinition->getArgument(1)); + $this->assertSame('test', (string) $surrealDbMessageStoreDefinition->getArgument(2)); + $this->assertSame('test', (string) $surrealDbMessageStoreDefinition->getArgument(3)); + $this->assertSame('foo', (string) $surrealDbMessageStoreDefinition->getArgument(4)); + $this->assertSame('bar', (string) $surrealDbMessageStoreDefinition->getArgument(5)); + $this->assertInstanceOf(Reference::class, $surrealDbMessageStoreDefinition->getArgument(6)); + $this->assertSame('serializer', (string) $surrealDbMessageStoreDefinition->getArgument(6)); + $this->assertSame('custom', (string) $surrealDbMessageStoreDefinition->getArgument(7)); + + $this->assertTrue($surrealDbMessageStoreDefinition->hasTag('proxy')); + $this->assertSame([['interface' => MessageStoreInterface::class]], $surrealDbMessageStoreDefinition->getTag('proxy')); + $this->assertTrue($surrealDbMessageStoreDefinition->hasTag('ai.message_store')); + } + + public function testSurrealDbMessageStoreIsConfiguredWithCustomTable() + { + $container = $this->buildContainer([ + 'ai' => [ + 'message_store' => [ + 'surreal_db' => [ + 'custom' => [ + 'endpoint' => 'http://127.0.0.1:8000', + 'username' => 'test', + 'password' => 'test', + 'namespace' => 'foo', + 'database' => 'bar', + 'table' => 'random', + ], + ], + ], + ], + ]); + + $surrealDbMessageStoreDefinition = $container->getDefinition('ai.message_store.surreal_db.custom'); + + $this->assertTrue($surrealDbMessageStoreDefinition->isLazy()); + $this->assertCount(8, $surrealDbMessageStoreDefinition->getArguments()); + $this->assertInstanceOf(Reference::class, $surrealDbMessageStoreDefinition->getArgument(0)); + $this->assertSame('http_client', (string) $surrealDbMessageStoreDefinition->getArgument(0)); + $this->assertSame('http://127.0.0.1:8000', (string) $surrealDbMessageStoreDefinition->getArgument(1)); + $this->assertSame('test', (string) $surrealDbMessageStoreDefinition->getArgument(2)); + $this->assertSame('test', (string) $surrealDbMessageStoreDefinition->getArgument(3)); + $this->assertSame('foo', (string) $surrealDbMessageStoreDefinition->getArgument(4)); + $this->assertSame('bar', (string) $surrealDbMessageStoreDefinition->getArgument(5)); + $this->assertInstanceOf(Reference::class, $surrealDbMessageStoreDefinition->getArgument(6)); + $this->assertSame('serializer', (string) $surrealDbMessageStoreDefinition->getArgument(6)); + $this->assertSame('random', (string) $surrealDbMessageStoreDefinition->getArgument(7)); + + $this->assertTrue($surrealDbMessageStoreDefinition->hasTag('proxy')); + $this->assertSame([['interface' => MessageStoreInterface::class]], $surrealDbMessageStoreDefinition->getTag('proxy')); + $this->assertTrue($surrealDbMessageStoreDefinition->hasTag('ai.message_store')); + } + + public function testSurrealDbMessageStoreIsConfiguredWithNamespacedUser() + { + $container = $this->buildContainer([ + 'ai' => [ + 'message_store' => [ + 'surreal_db' => [ + 'custom' => [ + 'endpoint' => 'http://127.0.0.1:8000', + 'username' => 'test', + 'password' => 'test', + 'namespace' => 'foo', + 'database' => 'bar', + 'namespaced_user' => true, + ], + ], + ], + ], + ]); + + $surrealDbMessageStoreDefinition = $container->getDefinition('ai.message_store.surreal_db.custom'); + + $this->assertTrue($surrealDbMessageStoreDefinition->isLazy()); + $this->assertCount(9, $surrealDbMessageStoreDefinition->getArguments()); + $this->assertInstanceOf(Reference::class, $surrealDbMessageStoreDefinition->getArgument(0)); + $this->assertSame('http_client', (string) $surrealDbMessageStoreDefinition->getArgument(0)); + $this->assertSame('http://127.0.0.1:8000', (string) $surrealDbMessageStoreDefinition->getArgument(1)); + $this->assertSame('test', (string) $surrealDbMessageStoreDefinition->getArgument(2)); + $this->assertSame('test', (string) $surrealDbMessageStoreDefinition->getArgument(3)); + $this->assertSame('foo', (string) $surrealDbMessageStoreDefinition->getArgument(4)); + $this->assertSame('bar', (string) $surrealDbMessageStoreDefinition->getArgument(5)); + $this->assertInstanceOf(Reference::class, $surrealDbMessageStoreDefinition->getArgument(6)); + $this->assertSame('serializer', (string) $surrealDbMessageStoreDefinition->getArgument(6)); + $this->assertSame('custom', (string) $surrealDbMessageStoreDefinition->getArgument(7)); + $this->assertTrue($surrealDbMessageStoreDefinition->getArgument(8)); + + $this->assertTrue($surrealDbMessageStoreDefinition->hasTag('proxy')); + $this->assertSame([['interface' => MessageStoreInterface::class]], $surrealDbMessageStoreDefinition->getTag('proxy')); + $this->assertTrue($surrealDbMessageStoreDefinition->hasTag('ai.message_store')); + } + private function buildContainer(array $configuration): ContainerBuilder { $container = new ContainerBuilder(); @@ -3440,6 +3557,25 @@ private function getFullConfig(): array 'identifier' => 'session', ], ], + 'surreal_db' => [ + 'my_surreal_db_message_store' => [ + 'endpoint' => 'http://127.0.0.1:8000', + 'username' => 'test', + 'password' => 'test', + 'namespace' => 'foo', + 'database' => 'bar', + 'namespaced_user' => true, + ], + 'my_surreal_db_message_store_with_custom_table' => [ + 'endpoint' => 'http://127.0.0.1:8000', + 'username' => 'test', + 'password' => 'test', + 'namespace' => 'foo', + 'database' => 'bar', + 'table' => 'bar', + 'namespaced_user' => true, + ], + ], ], 'chat' => [ 'main' => [ diff --git a/src/chat/CHANGELOG.md b/src/chat/CHANGELOG.md index cfb645f06..49f16b243 100644 --- a/src/chat/CHANGELOG.md +++ b/src/chat/CHANGELOG.md @@ -9,3 +9,4 @@ CHANGELOG - Meilisearch - Pogocache - Redis + - SurrealDb diff --git a/src/chat/src/Bridge/SurrealDb/MessageStore.php b/src/chat/src/Bridge/SurrealDb/MessageStore.php new file mode 100644 index 000000000..707a4c0be --- /dev/null +++ b/src/chat/src/Bridge/SurrealDb/MessageStore.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Bridge\SurrealDb; + +use Symfony\AI\Chat\Exception\InvalidArgumentException; +use Symfony\AI\Chat\Exception\RuntimeException; +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\AI\Chat\MessageNormalizer; +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final class MessageStore implements ManagedStoreInterface, MessageStoreInterface +{ + private string $authenticationToken = ''; + + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly string $endpointUrl, + private readonly string $user, + #[\SensitiveParameter] private readonly string $password, + private readonly string $namespace, + private readonly string $database, + private readonly SerializerInterface&NormalizerInterface&DenormalizerInterface $serializer = new Serializer([ + new ArrayDenormalizer(), + new MessageNormalizer(), + ], [new JsonEncoder()]), + private readonly string $table = '_message_store_surrealdb', + private readonly bool $isNamespacedUser = false, + ) { + } + + public function setup(array $options = []): void + { + if ([] !== $options) { + throw new InvalidArgumentException('No supported options.'); + } + } + + public function drop(): void + { + $this->request('DELETE', \sprintf('key/%s', $this->table)); + } + + public function save(MessageBag $messages): void + { + foreach ($messages->getMessages() as $message) { + $this->request('POST', \sprintf('key/%s', $this->table), $this->serializer->normalize($message)); + } + } + + public function load(): MessageBag + { + $messages = $this->request('GET', \sprintf('key/%s', $this->table), []); + + return new MessageBag(...array_map( + fn (array $message): MessageInterface => $this->serializer->denormalize($message, MessageInterface::class), + $messages[0]['result'], + )); + } + + /** + * @param array $payload + * + * @return array + */ + private function request(string $method, string $endpoint, array $payload = []): array + { + $this->authenticate(); + + $finalPayload = []; + + if ([] !== $payload) { + $finalPayload = [ + 'json' => $payload, + ]; + } + + $response = $this->httpClient->request($method, \sprintf('%s/%s', $this->endpointUrl, $endpoint), [ + ...$finalPayload, + ...[ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Surreal-NS' => $this->namespace, + 'Surreal-DB' => $this->database, + 'Authorization' => \sprintf('Bearer %s', $this->authenticationToken), + ], + ], + ]); + + return $response->toArray(); + } + + private function authenticate(): void + { + if ('' !== $this->authenticationToken) { + return; + } + + $authenticationPayload = [ + 'user' => $this->user, + 'pass' => $this->password, + ]; + + if ($this->isNamespacedUser) { + $authenticationPayload['ns'] = $this->namespace; + $authenticationPayload['db'] = $this->database; + } + + $authenticationResponse = $this->httpClient->request('POST', \sprintf('%s/signin', $this->endpointUrl), [ + 'headers' => [ + 'Accept' => 'application/json', + ], + 'json' => $authenticationPayload, + ]); + + $payload = $authenticationResponse->toArray(); + + if (!\array_key_exists('token', $payload)) { + throw new RuntimeException('The SurrealDB authentication response does not contain a token.'); + } + + $this->authenticationToken = $payload['token']; + } +} diff --git a/src/chat/tests/Bridge/SurrealDb/MessageStoreTest.php b/src/chat/tests/Bridge/SurrealDb/MessageStoreTest.php new file mode 100644 index 000000000..cdf3a8d71 --- /dev/null +++ b/src/chat/tests/Bridge/SurrealDb/MessageStoreTest.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Bridge\SurrealDb; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Bridge\SurrealDb\MessageStore; +use Symfony\AI\Chat\Exception\InvalidArgumentException; +use Symfony\AI\Chat\MessageNormalizer; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Serializer; + +final class MessageStoreTest extends TestCase +{ + public function testStoreCannotSetupWithExtraOptions() + { + $store = new MessageStore(new MockHttpClient(), 'http://127.0.0.1:8000', 'test', 'test', 'test', 'test'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No supported options.'); + $this->expectExceptionCode(0); + $store->setup([ + 'foo' => 'bar', + ]); + } + + public function testStoreCannotDropOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 200, + 'details' => 'Authentication succeeded.', + 'token' => 'bar', + ], [ + 'http_code' => 200, + ]), + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ], 'http://127.0.0.1:8000'); + + $store = new MessageStore($httpClient, 'http://127.0.0.1:8000', 'test', 'test', 'test', 'test', table: 'test'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:8000/key/test".'); + $this->expectExceptionCode(400); + $store->drop(); + } + + public function testStoreCanDrop() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 200, + 'details' => 'Authentication succeeded.', + 'token' => 'bar', + ], [ + 'http_code' => 200, + ]), + new JsonMockResponse([ + [ + 'result' => [], + 'status' => 'OK', + 'time' => '151.542µs', + ], + ], [ + 'http_code' => 200, + ]), + ], 'http://127.0.0.1:8000'); + + $store = new MessageStore($httpClient, 'http://127.0.0.1:8000', 'test', 'test', 'test', 'test'); + + $store->setup(); + $store->drop(); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testStoreCannotSaveOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 200, + 'details' => 'Authentication succeeded.', + 'token' => 'bar', + ], [ + 'http_code' => 200, + ]), + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ], 'http://127.0.0.1:8000'); + + $store = new MessageStore($httpClient, 'http://127.0.0.1:8000', 'test', 'test', 'test', 'test', table: 'test'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:8000/key/test".'); + $this->expectExceptionCode(400); + $store->save(new MessageBag(Message::ofUser('Hello world'))); + } + + public function testStoreCanSave() + { + $serializer = new Serializer([ + new ArrayDenormalizer(), + new MessageNormalizer(), + ], [new JsonEncoder()]); + + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 200, + 'details' => 'Authentication succeeded.', + 'token' => 'bar', + ], [ + 'http_code' => 200, + ]), + new JsonMockResponse([ + [ + 'result' => [ + $serializer->normalize(Message::ofUser('Hello world')), + ], + 'status' => 'OK', + 'time' => '263.208µs', + ], + ], [ + 'http_code' => 200, + ]), + ], 'http://127.0.0.1:8000'); + + $store = new MessageStore($httpClient, 'http://127.0.0.1:8000', 'test', 'test', 'test', 'test', table: 'test'); + + $store->save(new MessageBag(Message::ofUser('Hello world'))); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testStoreCannotLoadOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 200, + 'details' => 'Authentication succeeded.', + 'token' => 'bar', + ], [ + 'http_code' => 200, + ]), + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ], 'http://127.0.0.1:8000'); + + $store = new MessageStore($httpClient, 'http://127.0.0.1:8000', 'test', 'test', 'test', 'test', table: 'test'); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:8000/key/test".'); + $this->expectExceptionCode(400); + $store->load(); + } + + public function testStoreCanLoad() + { + $serializer = new Serializer([ + new ArrayDenormalizer(), + new MessageNormalizer(), + ], [new JsonEncoder()]); + + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 200, + 'details' => 'Authentication succeeded.', + 'token' => 'bar', + ], [ + 'http_code' => 200, + ]), + new JsonMockResponse([ + [ + 'result' => [ + $serializer->normalize(Message::ofUser('Hello World')), + ], + 'status' => 'OK', + 'time' => '263.208µs', + ], + ], [ + 'http_code' => 200, + ]), + ], 'http://127.0.0.1:8000'); + + $store = new MessageStore($httpClient, 'http://127.0.0.1:8000', 'test', 'test', 'test', 'test', table: 'test'); + + $messages = $store->load(); + + $this->assertSame(2, $httpClient->getRequestsCount()); + $this->assertCount(1, $messages); + + $message = $messages->getUserMessage(); + $this->assertSame('Hello World', $message->asText()); + } +}