From bc6e14298988a7fbb2a22f8f2f894bea32a47091 Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Thu, 25 Jul 2024 12:15:02 +0300 Subject: [PATCH] Add AsyncRPCInterface in AsyncCache, improve unit tests (#41) --- src/AsyncCache.php | 52 +- src/AsyncStorageInterface.php | 7 +- src/Cache.php | 4 +- src/Factory.php | 4 +- tests/AsyncCacheTest.php | 698 ++------------------------ tests/CacheTest.php | 663 +----------------------- tests/CacheTestCase.php | 644 ++++++++++++++++++++++++ tests/FactoryTest.php | 25 +- tests/Stub/AsyncRPCConnectionStub.php | 2 + tests/TestCase.php | 2 - 10 files changed, 718 insertions(+), 1383 deletions(-) create mode 100644 tests/CacheTestCase.php diff --git a/src/AsyncCache.php b/src/AsyncCache.php index b37b5b3..dc4d559 100644 --- a/src/AsyncCache.php +++ b/src/AsyncCache.php @@ -1,23 +1,21 @@ rpc instanceof AsyncRPCInterface); } /** * Note: The current PSR-16 implementation always returns true or * exception on error. * - * {@inheritDoc} - * * @throws KeyValueException * @throws RPCException */ @@ -59,19 +51,15 @@ public function deleteAsync(string $key): bool * Note: The current PSR-16 implementation always returns true or * exception on error. * - * {@inheritDoc} - * - * @psalm-param iterable $keys + * @param iterable $keys * * @throws KeyValueException * @throws RPCException */ public function deleteMultipleAsync(iterable $keys): bool { - assert($this->rpc instanceof AsyncRPCInterface); - // Handle someone never calling commitAsync() - if (count($this->callsInFlight) > 1000) { + if (\count($this->callsInFlight) > 1000) { $this->commitAsync(); } @@ -81,33 +69,29 @@ public function deleteMultipleAsync(iterable $keys): bool } /** - * {@inheritDoc} - * - * @psalm-param positive-int|\DateInterval|null $ttl + * @param positive-int|\DateInterval|null $ttl * @psalm-suppress MoreSpecificImplementedParamType + * * @throws KeyValueException * @throws RPCException */ - public function setAsync(string $key, mixed $value, null|int|DateInterval $ttl = null): bool + public function setAsync(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool { return $this->setMultipleAsync([$key => $value], $ttl); } /** - * {@inheritDoc} - * - * @psalm-param iterable $values - * @psalm-param positive-int|\DateInterval|null $ttl + * @param iterable $values + * @param positive-int|\DateInterval|null $ttl * @psalm-suppress MoreSpecificImplementedParamType + * * @throws KeyValueException * @throws RPCException */ - public function setMultipleAsync(iterable $values, null|int|DateInterval $ttl = null): bool + public function setMultipleAsync(iterable $values, null|int|\DateInterval $ttl = null): bool { - assert($this->rpc instanceof AsyncRPCInterface); - // Handle someone never calling commitAsync() - if (count($this->callsInFlight) > 1000) { + if (\count($this->callsInFlight) > 1000) { $this->commitAsync(); } @@ -125,15 +109,13 @@ public function setMultipleAsync(iterable $values, null|int|DateInterval $ttl = */ public function commitAsync(): bool { - assert($this->rpc instanceof AsyncRPCInterface); - try { $this->rpc->getResponses($this->callsInFlight, Response::class); } catch (ServiceException $e) { - $message = str_replace(["\t", "\n"], ' ', $e->getMessage()); + $message = \str_replace(["\t", "\n"], ' ', $e->getMessage()); - if (str_contains($message, 'no such storage')) { - throw new StorageException(sprintf(self::ERROR_INVALID_STORAGE, $this->name)); + if (\str_contains($message, 'no such storage')) { + throw new StorageException(\sprintf(self::ERROR_INVALID_STORAGE, $this->name)); } throw new KeyValueException($message, $e->getCode(), $e); diff --git a/src/AsyncStorageInterface.php b/src/AsyncStorageInterface.php index 8c4bb4e..3c8dfb2 100644 --- a/src/AsyncStorageInterface.php +++ b/src/AsyncStorageInterface.php @@ -1,8 +1,9 @@ rpc = $rpc->withCodec(new ProtobufCodec()); $this->zone = new \DateTimeZone('UTC'); diff --git a/src/Factory.php b/src/Factory.php index 2692d37..b3dac6b 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -28,8 +28,8 @@ public function select(string $name): StorageInterface { if ($this->rpc instanceof AsyncRPCInterface) { return new AsyncCache($this->rpc, $name, $this->getSerializer()); - } else { - return new Cache($this->rpc, $name, $this->getSerializer()); } + + return new Cache($this->rpc, $name, $this->getSerializer()); } } diff --git a/tests/AsyncCacheTest.php b/tests/AsyncCacheTest.php index 266ec00..80e6807 100644 --- a/tests/AsyncCacheTest.php +++ b/tests/AsyncCacheTest.php @@ -4,468 +4,42 @@ namespace Spiral\RoadRunner\KeyValue\Tests; +use PHPUnit\Framework\Attributes\DataProvider; use RoadRunner\KV\DTO\V1\Item; use RoadRunner\KV\DTO\V1\Request; -use RoadRunner\KV\DTO\V1\Response; use Spiral\Goridge\RPC\Exception\ServiceException; use Spiral\RoadRunner\KeyValue\AsyncCache; use Spiral\RoadRunner\KeyValue\Exception\InvalidArgumentException; use Spiral\RoadRunner\KeyValue\Exception\KeyValueException; -use Spiral\RoadRunner\KeyValue\Exception\NotImplementedException; -use Spiral\RoadRunner\KeyValue\Exception\SerializationException; -use Spiral\RoadRunner\KeyValue\Exception\StorageException; use Spiral\RoadRunner\KeyValue\Serializer\DefaultSerializer; -use Spiral\RoadRunner\KeyValue\Serializer\IgbinarySerializer; use Spiral\RoadRunner\KeyValue\Serializer\SerializerInterface; -use Spiral\RoadRunner\KeyValue\Serializer\SodiumSerializer; use Spiral\RoadRunner\KeyValue\Tests\Stub\AsyncFrozenDateCacheStub; -use Spiral\RoadRunner\KeyValue\Tests\Stub\RawSerializerStub; -class AsyncCacheTest extends TestCase +final class AsyncCacheTest extends CacheTestCase { - /** - * @psalm-suppress PropertyNotSetInConstructor - */ - private string $name; - - public function setUp(): void - { - $this->name = \bin2hex(\random_bytes(32)); - parent::setUp(); - } - - public function testName(): void - { - $driver = $this->cache(); - - $this->assertSame($this->name, $driver->getName()); - } - /** * @param array $mapping - * @param SerializerInterface|null $serializer */ - private function cache(array $mapping = [], SerializerInterface $serializer = new DefaultSerializer()): AsyncCache - { + protected function cache( + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer() + ): AsyncCache { return new AsyncCache($this->asyncRPC($mapping), $this->name, $serializer); } /** - * @dataProvider serializersDataProvider - */ - public function testTtl(SerializerInterface $serializer): void - { - [$key, $expected] = [$this->randomString(), $this->now()]; - - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $key, - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $actual = $driver->getTtl($key); - - $this->assertNotNull($actual); - $this->assertEquals($expected, $actual); - } - - private function randomString(int $len = 32): string - { - return \bin2hex(\random_bytes($len)); - } - - /** - * Returns normalized datetime without milliseconds - * - * @return \DateTimeInterface - */ - private function now(): \DateTimeInterface - { - $time = (new \DateTime())->format(\DateTimeInterface::RFC3339); - - return \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $time); - } - - /** - * @param array $items - */ - private function response(array $items = []): string - { - return (new Response(['items' => $items]))->serializeToString(); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testNoTtl(SerializerInterface $serializer): void - { - $driver = $this->cache(['kv.TTL' => $this->response()], $serializer); - - $this->assertNull($driver->getTtl('key')); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testMultipleTtl(SerializerInterface $serializer): void - { - $keys = [$this->randomString(), $this->randomString()]; - $expected = $this->now(); - - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $keys[0], - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - new Item([ - 'key' => $keys[1], - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $actual = $driver->getMultipleTtl($keys); - - foreach ($actual as $key => $time) { - $this->assertContains($key, $keys); - $this->assertEquals($expected, $time); - } - } - - /** - * @dataProvider serializersDataProvider - */ - public function testMultipleTtlWithMissingTime(SerializerInterface $serializer): void - { - $keys = [$this->randomString(), $this->randomString(), $this->randomString(), $this->randomString()]; - $expected = $this->now(); - - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $keys[0], - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $actual = $driver->getMultipleTtl($keys); - - foreach ($actual as $key => $time) { - $this->assertContains($key, $keys); - - $expectedForKey = $key === $keys[0] ? $expected : null; - $this->assertEquals($expectedForKey, $time); - } - } - - /** - * @dataProvider serializersDataProvider - */ - public function testTtlWithInvalidResponseKey(SerializerInterface $serializer): void - { - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $this->randomString(), - 'value' => $serializer->serialize(null), - 'timeout' => $this->now()->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $this->assertNull($driver->getTtl('__invalid__')); - } - - /** - * @return array - */ - public static function methodsDataProvider(): array - { - return [ - 'getTtl' => [fn (AsyncCache $c) => $c->getTtl('key')], - 'getMultipleTtl' => [fn (AsyncCache $c) => $c->getMultipleTtl(['key'])], - 'get' => [fn (AsyncCache $c) => $c->get('key')], - 'set' => [fn (AsyncCache $c) => $c->set('key', 'value')], - 'setAsync' => [fn (AsyncCache $c) => $c->setAsync('key', 'value') && $c->commitAsync()], - 'getMultiple' => [fn (AsyncCache $c) => $c->getMultiple(['key'])], - 'setMultiple' => [fn (AsyncCache $c) => $c->setMultiple(['key' => 'value'])], - 'setMultipleAsync' => [fn (AsyncCache $c) => $c->setMultiple(['key' => 'value']) && $c->commitAsync()], - 'deleteMultiple' => [fn (AsyncCache $c) => $c->deleteMultiple(['key'])], - 'deleteMultipleAsync' => [fn (AsyncCache $c) => $c->deleteMultipleAsync(['key']) && $c->commitAsync()], - 'delete' => [fn (AsyncCache $c) => $c->delete('key')], - 'deleteAsync' => [fn (AsyncCache $c) => $c->delete('key') && $c->commitAsync()], - 'has' => [fn (AsyncCache $c) => $c->has('key')], - ]; - } - - /** - * @param callable $handler (AsyncCache) $handler - * @dataProvider methodsDataProvider - */ - public function testBadStorageNameOnAnyMethodExecution(callable $handler): void - { - // When RPC ServiceException like - $error = function () { - throw new ServiceException('no such storage "' . $this->name . '"'); - }; - - // Then expects message like that cache storage has not been defined - $this->expectException(StorageException::class); - $this->expectExceptionMessage( - \sprintf( - 'Storage "%s" has not been defined. Please make sure your ' . - 'RoadRunner "kv" configuration contains a storage key named "%1$s"', - $this->name, - ), - ); - - $driver = $this->cache([ - 'kv.Has' => $error, - 'kv.Set' => $error, - 'kv.MGet' => $error, - 'kv.MExpire' => $error, - 'kv.TTL' => $error, - 'kv.Delete' => $error, - ]); - - $result = $handler($driver); - - // When the generator returns, then no error occurs - if ($result instanceof \Generator) { - \iterator_to_array($result); - } - } - - public function testTtlNotAvailable(): void - { - // When RPC ServiceException like - $error = function () { - throw new ServiceException('memcached_plugin_ttl: ttl not available'); - }; - - // Then expects message like that TTL not available - $this->expectException(NotImplementedException::class); - $this->expectExceptionMessage( - \sprintf( - 'Storage "%s" does not support kv.TTL RPC method execution. Please ' . - 'use another driver for the storage if you require this functionality', - $this->name, - ), - ); - - $driver = $this->cache(['kv.TTL' => $error]); - - $driver->getTtl('key'); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testGet(SerializerInterface $serializer): void - { - $expected = $this->randomString(1024); - - $driver = $this->cache([ - 'kv.MGet' => $this->response([ - new Item(['key' => 'key', 'value' => $serializer->serialize($expected)]), - ]), - ], $serializer); - - $this->assertSame($expected, $driver->get('key')); - } - - public function testGetWhenValueNotExists(): void - { - $driver = $this->cache(['kv.MGet' => $this->response()]); - - $this->assertNull($driver->get('key')); - } - - public function testGetDefaultWhenValueNotExists(): void - { - $expected = $this->randomString(); - - $driver = $this->cache(['kv.MGet' => $this->response()]); - - $this->assertSame($expected, $driver->get('key', $expected)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testGetMultiple(SerializerInterface $serializer): void - { - $expected = [ - 'key0' => $this->randomString(), - 'key1' => $this->randomString(), - 'key2' => null, - 'key3' => null, - ]; - - $driver = $this->cache([ - // Only 2 items of 4 should be returned - 'kv.MGet' => $this->response([ - new Item(['key' => 'key0', 'value' => $serializer->serialize($expected['key0'])]), - new Item(['key' => 'key1', 'value' => $serializer->serialize($expected['key1'])]), - ]), - ], $serializer); - - $actual = $driver->getMultiple(\array_keys($expected)); - - $this->assertSame($expected, \iterator_to_array($actual)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testHas(SerializerInterface $serializer): void - { - $key = $this->randomString(); - - $driver = $this->cache([ - 'kv.Has' => $this->response([ - new Item(['key' => $key, 'value' => $serializer->serialize(null)]), - ]), - ], $serializer); - - $this->assertTrue($driver->has($key)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testHasWhenNotExists(SerializerInterface $serializer): void - { - $key = $this->randomString(); - - $driver = $this->cache([ - 'kv.Has' => $this->response(), - ], $serializer); - - $this->assertFalse($driver->has($key)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testHasWithInvalidResponse(SerializerInterface $serializer): void - { - $key = $this->randomString(); - - $driver = $this->cache([ - 'kv.Has' => $this->response([ - new Item(['key' => $key, 'value' => $serializer->serialize(null)]), - ]), - ], $serializer); - - $this->assertFalse($driver->has('__invalid_key__')); - } - - public function testClear(): void - { - $driver = $this->cache(['kv.Clear' => $this->response()]); - - $result = $driver->clear(); - - $this->assertTrue($result); - } - - public function testClearError(): void - { - $this->expectException(KeyValueException::class); - $this->expectExceptionMessage('Something went wrong'); - - $driver = $this->cache([ - 'kv.Clear' => function () { - throw new ServiceException('Something went wrong'); - }, - ]); - - $driver->clear(); - } - - public function testClearMethodNotFoundError(): void - { - $this->expectException(KeyValueException::class); - $this->expectExceptionMessage( - 'RoadRunner does not support kv.Clear RPC method. ' . - 'Please make sure you are using RoadRunner v2.3.1 or higher.', - ); - - $driver = $this->cache(); - $driver->clear(); - } - - public static function serializersWithValuesDataProvider(): array - { - $result = []; - - foreach (self::serializersDataProvider() as $name => [$serializer]) { - foreach (self::valuesDataProvider() as $type => [$value]) { - $result['[' . $type . '] using [' . $name . ']'] = [$serializer, $value]; - } - } - - return $result; - } - - /** - * @return array - * @throws \SodiumException - */ - public static function serializersDataProvider(): array - { - $result = []; - $result['PHP Serialize'] = [new DefaultSerializer()]; - - // ext-igbinary required for this serializer - if (\extension_loaded('igbinary')) { - $result['Igbinary'] = [new IgbinarySerializer()]; - } - - // ext-sodium required for this serialize - if (\extension_loaded('sodium')) { - foreach ($result as $name => [$serializer]) { - $result['Sodium through ' . $name] = [ - new SodiumSerializer($serializer, \sodium_crypto_box_keypair()), - ]; - } - } - - return $result; - } - - /** - * @dataProvider serializersWithValuesDataProvider + * @param array $mapping */ - public function testSet(SerializerInterface $serializer, $expected): void - { - if (\is_float($expected) && \is_nan($expected)) { - $this->markTestSkipped('Unable to execute test for NAN float value'); - } - - if (\is_resource($expected)) { - $this->markTestSkipped('Unable to execute test for resource value'); - } - - $driver = $this->getAssertableCacheOnSet($serializer, ['key' => $expected]); - - $driver->set('key', $expected); + protected function frozenDateCache( + \DateTimeImmutable $date, + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer(), + ): AsyncCache { + return new AsyncFrozenDateCacheStub($date, $this->asyncRPC($mapping), $this->name, $serializer); } - /** - * @dataProvider serializersWithValuesDataProvider - */ - public function testSetAsync(SerializerInterface $serializer, $expected): void + #[DataProvider('serializersWithValuesDataProvider')] + public function testSetAsync(SerializerInterface $serializer, mixed $expected): void { if (\is_float($expected) && \is_nan($expected)) { $this->markTestSkipped('Unable to execute test for NAN float value'); @@ -481,57 +55,8 @@ public function testSetAsync(SerializerInterface $serializer, $expected): void $driver->commitAsync(); } - /** - * @param SerializerInterface $serializer - * @param array $expected - * @return AsyncCache - */ - private function getAssertableCacheOnSet(SerializerInterface $serializer, array $expected): AsyncCache - { - return $this->cache([ - 'kv.Set' => function (Request $request) use ($serializer, $expected): string { - $items = $request->getItems(); - - $result = []; - - /** @var Item $item */ - foreach ($items as $item) { - $result[] = $item; - - $this->assertArrayHasKey($item->getKey(), $expected); - $this->assertEquals($expected[$item->getKey()], $serializer->unserialize($item->getValue())); - } - - $this->assertSame($items->count(), \count($expected)); - - return $this->response($result); - }, - ], $serializer); - } - - /** - * @dataProvider serializersWithValuesDataProvider - */ - public function testMultipleSet(SerializerInterface $serializer, $value): void - { - if (\is_float($value) && \is_nan($value)) { - $this->markTestSkipped('Unable to execute test for NAN float value'); - } - - if (\is_resource($value)) { - $this->markTestSkipped('Unable to execute test for resource value'); - } - - $expected = ['key' => $value, 'key2' => $value]; - - $driver = $this->getAssertableCacheOnSet($serializer, $expected); - $driver->setMultiple($expected); - } - - /** - * @dataProvider serializersWithValuesDataProvider - */ - public function testMultipleSetAsync(SerializerInterface $serializer, $value): void + #[DataProvider('serializersWithValuesDataProvider')] + public function testMultipleSetAsync(SerializerInterface $serializer, mixed $value): void { if (\is_float($value) && \is_nan($value)) { $this->markTestSkipped('Unable to execute test for NAN float value'); @@ -548,30 +73,6 @@ public function testMultipleSetAsync(SerializerInterface $serializer, $value): v $driver->commitAsync(); } - public function testSetWithRelativeIntTTL(): void - { - $seconds = 0xDEAD_BEEF; - - // This is the current time for cache and relative date - $now = new \DateTimeImmutable(); - // Relative date: [$now] + [$seconds] - $expected = $now->add(new \DateInterval("PT{$seconds}S")) - ->format(\DateTimeInterface::RFC3339); - - $driver = $this->frozenDateCache($now, [ - 'kv.Set' => function (Request $request) use ($expected) { - /** @var Item $item */ - $item = $request->getItems()[0]; - $this->assertSame($expected, $item->getTimeout()); - - return $this->response(); - }, - ]); - - // Send relative date in $now + $seconds - $driver->set('key', 'value', $seconds); - } - public function testSetAsyncWithRelativeIntTTL(): void { $seconds = 0xDEAD_BEEF; @@ -597,43 +98,6 @@ public function testSetAsyncWithRelativeIntTTL(): void $driver->commitAsync(); } - /** - * @param array $mapping - * @param SerializerInterface $serializer - */ - private function frozenDateCache( - \DateTimeImmutable $date, - array $mapping = [], - SerializerInterface $serializer = new DefaultSerializer(), - ): AsyncCache { - return new AsyncFrozenDateCacheStub($date, $this->asyncRPC($mapping), $this->name, $serializer); - } - - public function testSetWithRelativeDateIntervalTTL(): void - { - $seconds = 0xDEAD_BEEF; - $interval = new \DateInterval("PT{$seconds}S"); - - // This is the current time for cache and relative date - $now = new \DateTimeImmutable(); - - // Add interval to frozen current time - $expected = $now->add($interval) - ->format(\DateTimeInterface::RFC3339); - - $driver = $this->frozenDateCache($now, [ - 'kv.Set' => function (Request $request) use ($expected) { - /** @var Item $item */ - $item = $request->getItems()[0]; - $this->assertSame($expected, $item->getTimeout()); - - return $this->response(); - }, - ]); - - $driver->set('key', 'value', $interval); - } - public function testSetAsyncWithRelativeDateIntervalTTL(): void { $seconds = 0xDEAD_BEEF; @@ -660,32 +124,8 @@ public function testSetAsyncWithRelativeDateIntervalTTL(): void $driver->commitAsync(); } - /** - * @dataProvider valuesDataProvider - */ - public function testSetWithInvalidTTL($invalidTTL): void - { - $type = \get_debug_type($invalidTTL); - - if ($invalidTTL === null || \is_int($invalidTTL) || $invalidTTL instanceof \DateTimeInterface) { - $this->markTestSkipped('Can not complete negative test for valid TTL of type ' . $type); - } - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Cache item ttl (expiration) must be of type int or \DateInterval, but ' . $type . ' passed', - ); - - $driver = $this->cache(); - - // Send relative date in $now + $seconds - $driver->set('key', 'value', $invalidTTL); - } - - /** - * @dataProvider valuesDataProvider - */ - public function testSetAsyncWithInvalidTTL($invalidTTL): void + #[DataProvider('valuesDataProvider')] + public function testSetAsyncWithInvalidTTL(mixed $invalidTTL): void { $type = \get_debug_type($invalidTTL); @@ -707,12 +147,6 @@ public function testSetAsyncWithInvalidTTL($invalidTTL): void $driver->commitAsync(); } - public function testDelete(): void - { - $driver = $this->cache(['kv.Delete' => $this->response([])]); - $this->assertTrue($driver->delete('key')); - } - public function testDeleteAsync(): void { $driver = $this->cache(['kv.Delete' => $this->response([])]); @@ -720,19 +154,6 @@ public function testDeleteAsync(): void $this->assertTrue($driver->commitAsync()); } - public function testDeleteWithError(): void - { - $this->expectException(KeyValueException::class); - - $driver = $this->cache([ - 'kv.Delete' => function () { - throw new ServiceException('Error: Can not delete something'); - }, - ]); - - $driver->delete('key'); - } - public function testDeleteAsyncWithError(): void { $driver = $this->cache([ @@ -746,12 +167,6 @@ public function testDeleteAsyncWithError(): void $driver->commitAsync(); } - public function testDeleteMultiple(): void - { - $driver = $this->cache(['kv.Delete' => $this->response([])]); - $this->assertTrue($driver->deleteMultiple(['key', 'key2'])); - } - public function testDeleteMultipleAsync(): void { $driver = $this->cache(['kv.Delete' => $this->response([])]); @@ -759,19 +174,6 @@ public function testDeleteMultipleAsync(): void $this->assertTrue($driver->commitAsync()); } - public function testDeleteMultipleWithError(): void - { - $this->expectException(KeyValueException::class); - - $driver = $this->cache([ - 'kv.Delete' => function () { - throw new ServiceException('Error: Can not delete something'); - }, - ]); - - $driver->deleteMultiple(['key', 'key2']); - } - public function testDeleteMultipleAsyncWithError(): void { $driver = $this->cache([ @@ -785,26 +187,6 @@ public function testDeleteMultipleAsyncWithError(): void $driver->commitAsync(); } - public function testGetMultipleWithInvalidKey(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cache key must be a string, but int passed'); - - $driver = $this->cache(); - foreach ($driver->getMultiple([0 => 0xDEAD_BEEF]) as $_) { - // - } - } - - public function testSetMultipleWithInvalidKey(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cache key must be a string, but int passed'); - - $driver = $this->cache(); - $driver->setMultiple([0 => 0xDEAD_BEEF]); - } - public function testSetAsyncMultipleWithInvalidKey(): void { $this->expectException(InvalidArgumentException::class); @@ -817,15 +199,6 @@ public function testSetAsyncMultipleWithInvalidKey(): void $driver->commitAsync(); } - public function testDeleteMultipleWithInvalidKey(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cache key must be a string, but int passed'); - - $driver = $this->cache(); - $driver->deleteMultiple([0 => 0xDEAD_BEEF]); - } - public function testDeleteMultipleAsyncWithInvalidKey(): void { $this->expectException(InvalidArgumentException::class); @@ -838,31 +211,16 @@ public function testDeleteMultipleAsyncWithInvalidKey(): void $driver->commitAsync(); } - public function testImmutableWhileSwitchSerialization(): void - { - $expected = $this->randomString(1024); - - $driver = $this->cache([ - 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), - ], new RawSerializerStub()); - - $decorated = $driver->withSerializer(new DefaultSerializer()); - - // Behaviour MUST NOT be changed - $this->assertSame($expected, $driver->get('key')); - } - - public function testErrorOnInvalidSerialization(): void + /** + * @return \Traversable + */ + public static function methodsDataProvider(): \Traversable { - $this->expectException(SerializationException::class); - - $expected = $this->randomString(1024); - - $driver = $this->cache([ - 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), - ], new RawSerializerStub()); + yield from parent::methodsDataProvider(); - $actual = $driver->withSerializer(new DefaultSerializer()) - ->get('key'); + yield 'setAsync' => [fn (AsyncCache $c) => $c->setAsync('key', 'value') && $c->commitAsync()]; + yield 'setMultipleAsync' => [fn (AsyncCache $c) => $c->setMultiple(['key' => 'value']) && $c->commitAsync()]; + yield 'deleteMultipleAsync' => [fn (AsyncCache $c) => $c->deleteMultipleAsync(['key']) && $c->commitAsync()]; + yield 'deleteAsync' => [fn (AsyncCache $c) => $c->delete('key') && $c->commitAsync()]; } } diff --git a/tests/CacheTest.php b/tests/CacheTest.php index 63e117c..822ee86 100644 --- a/tests/CacheTest.php +++ b/tests/CacheTest.php @@ -4,682 +4,31 @@ namespace Spiral\RoadRunner\KeyValue\Tests; -use RoadRunner\KV\DTO\V1\Item; -use RoadRunner\KV\DTO\V1\Request; -use RoadRunner\KV\DTO\V1\Response; -use Spiral\Goridge\RPC\Exception\ServiceException; use Spiral\RoadRunner\KeyValue\Cache; -use Spiral\RoadRunner\KeyValue\Exception\InvalidArgumentException; -use Spiral\RoadRunner\KeyValue\Exception\KeyValueException; -use Spiral\RoadRunner\KeyValue\Exception\NotImplementedException; -use Spiral\RoadRunner\KeyValue\Exception\SerializationException; -use Spiral\RoadRunner\KeyValue\Exception\StorageException; use Spiral\RoadRunner\KeyValue\Serializer\DefaultSerializer; -use Spiral\RoadRunner\KeyValue\Serializer\IgbinarySerializer; use Spiral\RoadRunner\KeyValue\Serializer\SerializerInterface; -use Spiral\RoadRunner\KeyValue\Serializer\SodiumSerializer; use Spiral\RoadRunner\KeyValue\Tests\Stub\FrozenDateCacheStub; -use Spiral\RoadRunner\KeyValue\Tests\Stub\RawSerializerStub; -class CacheTest extends TestCase +final class CacheTest extends CacheTestCase { - /** - * @psalm-suppress PropertyNotSetInConstructor - */ - private string $name; - - public function setUp(): void - { - $this->name = \bin2hex(\random_bytes(32)); - parent::setUp(); - } - - public function testName(): void - { - $driver = $this->cache(); - - $this->assertSame($this->name, $driver->getName()); - } - /** * @param array $mapping - * @param SerializerInterface|null $serializer */ - private function cache(array $mapping = [], SerializerInterface $serializer = new DefaultSerializer()): Cache - { + protected function cache( + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer() + ): Cache { return new Cache($this->rpc($mapping), $this->name, $serializer); } - /** - * @dataProvider serializersDataProvider - */ - public function testTtl(SerializerInterface $serializer): void - { - [$key, $expected] = [$this->randomString(), $this->now()]; - - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $key, - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $actual = $driver->getTtl($key); - - $this->assertNotNull($actual); - $this->assertEquals($expected, $actual); - } - - private function randomString(int $len = 32): string - { - return \bin2hex(\random_bytes($len)); - } - - /** - * Returns normalized datetime without milliseconds - * - * @return \DateTimeInterface - */ - private function now(): \DateTimeInterface - { - $time = (new \DateTime())->format(\DateTimeInterface::RFC3339); - - return \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $time); - } - - /** - * @param array $items - */ - private function response(array $items = []): string - { - return (new Response(['items' => $items]))->serializeToString(); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testNoTtl(SerializerInterface $serializer): void - { - $driver = $this->cache(['kv.TTL' => $this->response()], $serializer); - - $this->assertNull($driver->getTtl('key')); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testMultipleTtl(SerializerInterface $serializer): void - { - $keys = [$this->randomString(), $this->randomString()]; - $expected = $this->now(); - - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $keys[0], - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - new Item([ - 'key' => $keys[1], - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $actual = $driver->getMultipleTtl($keys); - - foreach ($actual as $key => $time) { - $this->assertContains($key, $keys); - $this->assertEquals($expected, $time); - } - } - - /** - * @dataProvider serializersDataProvider - */ - public function testMultipleTtlWithMissingTime(SerializerInterface $serializer): void - { - $keys = [$this->randomString(), $this->randomString(), $this->randomString(), $this->randomString()]; - $expected = $this->now(); - - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $keys[0], - 'value' => $serializer->serialize(null), - 'timeout' => $expected->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $actual = $driver->getMultipleTtl($keys); - - foreach ($actual as $key => $time) { - $this->assertContains($key, $keys); - - $expectedForKey = $key === $keys[0] ? $expected : null; - $this->assertEquals($expectedForKey, $time); - } - } - - /** - * @dataProvider serializersDataProvider - */ - public function testTtlWithInvalidResponseKey(SerializerInterface $serializer): void - { - $driver = $this->cache([ - 'kv.TTL' => fn () => $this->response([ - new Item([ - 'key' => $this->randomString(), - 'value' => $serializer->serialize(null), - 'timeout' => $this->now()->format(\DateTimeInterface::RFC3339), - ]), - ]), - ], $serializer); - - $this->assertNull($driver->getTtl('__invalid__')); - } - - /** - * @return array - */ - public static function methodsDataProvider(): array - { - return [ - 'getTtl' => [fn (Cache $c) => $c->getTtl('key')], - 'getMultipleTtl' => [fn (Cache $c) => $c->getMultipleTtl(['key'])], - 'get' => [fn (Cache $c) => $c->get('key')], - 'set' => [fn (Cache $c) => $c->set('key', 'value')], - 'getMultiple' => [fn (Cache $c) => $c->getMultiple(['key'])], - 'setMultiple' => [fn (Cache $c) => $c->setMultiple(['key' => 'value'])], - 'deleteMultiple' => [fn (Cache $c) => $c->deleteMultiple(['key'])], - 'delete' => [fn (Cache $c) => $c->delete('key')], - 'has' => [fn (Cache $c) => $c->has('key')], - ]; - } - - /** - * @param callable(Cache) $handler - * @dataProvider methodsDataProvider - */ - public function testBadStorageNameOnAnyMethodExecution(callable $handler): void - { - // When RPC ServiceException like - $error = function () { - throw new ServiceException('no such storage "' . $this->name . '"'); - }; - - // Then expects message like that cache storage has not been defined - $this->expectException(StorageException::class); - $this->expectExceptionMessage( - \sprintf( - 'Storage "%s" has not been defined. Please make sure your ' . - 'RoadRunner "kv" configuration contains a storage key named "%1$s"', - $this->name, - ), - ); - - $driver = $this->cache([ - 'kv.Has' => $error, - 'kv.Set' => $error, - 'kv.MGet' => $error, - 'kv.MExpire' => $error, - 'kv.TTL' => $error, - 'kv.Delete' => $error, - ]); - - $result = $handler($driver); - - // When the generator returns, then no error occurs - if ($result instanceof \Generator) { - \iterator_to_array($result); - } - } - - public function testTtlNotAvailable(): void - { - // When RPC ServiceException like - $error = function () { - throw new ServiceException('memcached_plugin_ttl: ttl not available'); - }; - - // Then expects message like that TTL not available - $this->expectException(NotImplementedException::class); - $this->expectExceptionMessage( - \sprintf( - 'Storage "%s" does not support kv.TTL RPC method execution. Please ' . - 'use another driver for the storage if you require this functionality', - $this->name, - ), - ); - - $driver = $this->cache(['kv.TTL' => $error]); - - $driver->getTtl('key'); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testGet(SerializerInterface $serializer): void - { - $expected = $this->randomString(1024); - - $driver = $this->cache([ - 'kv.MGet' => $this->response([ - new Item(['key' => 'key', 'value' => $serializer->serialize($expected)]), - ]), - ], $serializer); - - $this->assertSame($expected, $driver->get('key')); - } - - public function testGetWhenValueNotExists(): void - { - $driver = $this->cache(['kv.MGet' => $this->response()]); - - $this->assertNull($driver->get('key')); - } - - public function testGetDefaultWhenValueNotExists(): void - { - $expected = $this->randomString(); - - $driver = $this->cache(['kv.MGet' => $this->response()]); - - $this->assertSame($expected, $driver->get('key', $expected)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testGetMultiple(SerializerInterface $serializer): void - { - $expected = [ - 'key0' => $this->randomString(), - 'key1' => $this->randomString(), - 'key2' => null, - 'key3' => null, - ]; - - $driver = $this->cache([ - // Only 2 items of 4 should be returned - 'kv.MGet' => $this->response([ - new Item(['key' => 'key0', 'value' => $serializer->serialize($expected['key0'])]), - new Item(['key' => 'key1', 'value' => $serializer->serialize($expected['key1'])]), - ]), - ], $serializer); - - $actual = $driver->getMultiple(\array_keys($expected)); - - $this->assertSame($expected, \iterator_to_array($actual)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testHas(SerializerInterface $serializer): void - { - $key = $this->randomString(); - - $driver = $this->cache([ - 'kv.Has' => $this->response([ - new Item(['key' => $key, 'value' => $serializer->serialize(null)]), - ]), - ], $serializer); - - $this->assertTrue($driver->has($key)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testHasWhenNotExists(SerializerInterface $serializer): void - { - $key = $this->randomString(); - - $driver = $this->cache([ - 'kv.Has' => $this->response(), - ], $serializer); - - $this->assertFalse($driver->has($key)); - } - - /** - * @dataProvider serializersDataProvider - */ - public function testHasWithInvalidResponse(SerializerInterface $serializer): void - { - $key = $this->randomString(); - - $driver = $this->cache([ - 'kv.Has' => $this->response([ - new Item(['key' => $key, 'value' => $serializer->serialize(null)]), - ]), - ], $serializer); - - $this->assertFalse($driver->has('__invalid_key__')); - } - - public function testClear(): void - { - $driver = $this->cache(['kv.Clear' => $this->response()]); - - $result = $driver->clear(); - - $this->assertTrue($result); - } - - public function testClearError(): void - { - $this->expectException(KeyValueException::class); - $this->expectExceptionMessage('Something went wrong'); - - $driver = $this->cache([ - 'kv.Clear' => function () { - throw new ServiceException('Something went wrong'); - }, - ]); - - $driver->clear(); - } - - public function testClearMethodNotFoundError(): void - { - $this->expectException(KeyValueException::class); - $this->expectExceptionMessage( - 'RoadRunner does not support kv.Clear RPC method. ' . - 'Please make sure you are using RoadRunner v2.3.1 or higher.', - ); - - $driver = $this->cache(); - $driver->clear(); - } - - public static function serializersWithValuesDataProvider(): array - { - $result = []; - - foreach (self::serializersDataProvider() as $name => [$serializer]) { - foreach (self::valuesDataProvider() as $type => [$value]) { - $result['[' . $type . '] using [' . $name . ']'] = [$serializer, $value]; - } - } - - return $result; - } - - /** - * @return array - * @throws \SodiumException - */ - public static function serializersDataProvider(): array - { - $result = []; - $result['PHP Serialize'] = [new DefaultSerializer()]; - - // ext-igbinary required for this serializer - if (\extension_loaded('igbinary')) { - $result['Igbinary'] = [new IgbinarySerializer()]; - } - - // ext-sodium required for this serialize - if (\extension_loaded('sodium')) { - foreach ($result as $name => [$serializer]) { - $result['Sodium through ' . $name] = [ - new SodiumSerializer($serializer, \sodium_crypto_box_keypair()), - ]; - } - } - - return $result; - } - - /** - * @dataProvider serializersWithValuesDataProvider - */ - public function testSet(SerializerInterface $serializer, $expected): void - { - if (\is_float($expected) && \is_nan($expected)) { - $this->markTestSkipped('Unable to execute test for NAN float value'); - } - - if (\is_resource($expected)) { - $this->markTestSkipped('Unable to execute test for resource value'); - } - - $driver = $this->getAssertableCacheOnSet($serializer, ['key' => $expected]); - - $driver->set('key', $expected); - } - - /** - * @param SerializerInterface $serializer - * @param array $expected - * @return Cache - */ - private function getAssertableCacheOnSet(SerializerInterface $serializer, array $expected): Cache - { - return $this->cache([ - 'kv.Set' => function (Request $request) use ($serializer, $expected): string { - $items = $request->getItems(); - - $result = []; - - /** @var Item $item */ - foreach ($items as $item) { - $result[] = $item; - - $this->assertArrayHasKey($item->getKey(), $expected); - $this->assertEquals($expected[$item->getKey()], $serializer->unserialize($item->getValue())); - } - - $this->assertSame($items->count(), \count($expected)); - - return $this->response($result); - }, - ], $serializer); - } - - /** - * @dataProvider serializersWithValuesDataProvider - */ - public function testMultipleSet(SerializerInterface $serializer, $value): void - { - if (\is_float($value) && \is_nan($value)) { - $this->markTestSkipped('Unable to execute test for NAN float value'); - } - - if (\is_resource($value)) { - $this->markTestSkipped('Unable to execute test for resource value'); - } - - $expected = ['key' => $value, 'key2' => $value]; - - $driver = $this->getAssertableCacheOnSet($serializer, $expected); - $driver->setMultiple($expected); - } - - public function testSetWithRelativeIntTTL(): void - { - $seconds = 0xDEAD_BEEF; - - // This is the current time for cache and relative date - $now = new \DateTimeImmutable(); - // Relative date: [$now] + [$seconds] - $expected = $now->add(new \DateInterval("PT{$seconds}S")) - ->format(\DateTimeInterface::RFC3339); - - $driver = $this->frozenDateCache($now, [ - 'kv.Set' => function (Request $request) use ($expected) { - /** @var Item $item */ - $item = $request->getItems()[0]; - $this->assertSame($expected, $item->getTimeout()); - - return $this->response(); - }, - ]); - - // Send relative date in $now + $seconds - $driver->set('key', 'value', $seconds); - } - /** * @param array $mapping - * @param SerializerInterface $serializer */ - private function frozenDateCache( + protected function frozenDateCache( \DateTimeImmutable $date, array $mapping = [], SerializerInterface $serializer = new DefaultSerializer(), ): Cache { return new FrozenDateCacheStub($date, $this->rpc($mapping), $this->name, $serializer); } - - public function testSetWithRelativeDateIntervalTTL(): void - { - $seconds = 0xDEAD_BEEF; - $interval = new \DateInterval("PT{$seconds}S"); - - // This is the current time for cache and relative date - $now = new \DateTimeImmutable(); - - // Add interval to frozen current time - $expected = $now->add($interval) - ->format(\DateTimeInterface::RFC3339); - - $driver = $this->frozenDateCache($now, [ - 'kv.Set' => function (Request $request) use ($expected) { - /** @var Item $item */ - $item = $request->getItems()[0]; - $this->assertSame($expected, $item->getTimeout()); - - return $this->response(); - }, - ]); - - $driver->set('key', 'value', $interval); - } - - /** - * @dataProvider valuesDataProvider - */ - public function testSetWithInvalidTTL($invalidTTL): void - { - $type = \get_debug_type($invalidTTL); - - if ($invalidTTL === null || \is_int($invalidTTL) || $invalidTTL instanceof \DateTimeInterface) { - $this->markTestSkipped('Can not complete negative test for valid TTL of type ' . $type); - } - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Cache item ttl (expiration) must be of type int or \DateInterval, but ' . $type . ' passed', - ); - - $driver = $this->cache(); - - // Send relative date in $now + $seconds - $driver->set('key', 'value', $invalidTTL); - } - - public function testDelete(): void - { - $driver = $this->cache(['kv.Delete' => $this->response([])]); - $this->assertTrue($driver->delete('key')); - } - - public function testDeleteWithError(): void - { - $this->expectException(KeyValueException::class); - - $driver = $this->cache([ - 'kv.Delete' => function () { - throw new ServiceException('Error: Can not delete something'); - }, - ]); - - $driver->delete('key'); - } - - public function testDeleteMultiple(): void - { - $driver = $this->cache(['kv.Delete' => $this->response([])]); - $this->assertTrue($driver->deleteMultiple(['key', 'key2'])); - } - - public function testDeleteMultipleWithError(): void - { - $this->expectException(KeyValueException::class); - - $driver = $this->cache([ - 'kv.Delete' => function () { - throw new ServiceException('Error: Can not delete something'); - }, - ]); - - $driver->deleteMultiple(['key', 'key2']); - } - - public function testGetMultipleWithInvalidKey(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cache key must be a string, but int passed'); - - $driver = $this->cache(); - foreach ($driver->getMultiple([0 => 0xDEAD_BEEF]) as $_) { - // - } - } - - public function testSetMultipleWithInvalidKey(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cache key must be a string, but int passed'); - - $driver = $this->cache(); - $driver->setMultiple([0 => 0xDEAD_BEEF]); - } - - public function testDeleteMultipleWithInvalidKey(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Cache key must be a string, but int passed'); - - $driver = $this->cache(); - $driver->deleteMultiple([0 => 0xDEAD_BEEF]); - } - - public function testImmutableWhileSwitchSerialization(): void - { - $expected = $this->randomString(1024); - - $driver = $this->cache([ - 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), - ], new RawSerializerStub()); - - $decorated = $driver->withSerializer(new DefaultSerializer()); - - // Behaviour MUST NOT be changed - $this->assertSame($expected, $driver->get('key')); - } - - public function testErrorOnInvalidSerialization(): void - { - $this->expectException(SerializationException::class); - - $expected = $this->randomString(1024); - - $driver = $this->cache([ - 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), - ], new RawSerializerStub()); - - $actual = $driver->withSerializer(new DefaultSerializer()) - ->get('key'); - } } diff --git a/tests/CacheTestCase.php b/tests/CacheTestCase.php new file mode 100644 index 0000000..7e0afa5 --- /dev/null +++ b/tests/CacheTestCase.php @@ -0,0 +1,644 @@ +name = \bin2hex(\random_bytes(32)); + parent::setUp(); + } + + /** + * @param array $mapping + */ + abstract protected function cache( + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer() + ): StorageInterface; + + /** + * @param array $mapping + */ + abstract protected function frozenDateCache( + \DateTimeImmutable $date, + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer(), + ): StorageInterface; + + public function testName(): void + { + $driver = $this->cache(); + + $this->assertSame($this->name, $driver->getName()); + } + + #[DataProvider('serializersDataProvider')] + public function testTtl(SerializerInterface $serializer): void + { + [$key, $expected] = [$this->randomString(), $this->now()]; + + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $key, + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $actual = $driver->getTtl($key); + + $this->assertNotNull($actual); + $this->assertEquals($expected, $actual); + } + + #[DataProvider('serializersDataProvider')] + public function testNoTtl(SerializerInterface $serializer): void + { + $driver = $this->cache(['kv.TTL' => $this->response()], $serializer); + + $this->assertNull($driver->getTtl('key')); + } + + #[DataProvider('serializersDataProvider')] + public function testMultipleTtl(SerializerInterface $serializer): void + { + $keys = [$this->randomString(), $this->randomString()]; + $expected = $this->now(); + + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $keys[0], + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + new Item([ + 'key' => $keys[1], + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $actual = $driver->getMultipleTtl($keys); + + foreach ($actual as $key => $time) { + $this->assertContains($key, $keys); + $this->assertEquals($expected, $time); + } + } + + #[DataProvider('serializersDataProvider')] + public function testMultipleTtlWithMissingTime(SerializerInterface $serializer): void + { + $keys = [$this->randomString(), $this->randomString(), $this->randomString(), $this->randomString()]; + $expected = $this->now(); + + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $keys[0], + 'value' => $serializer->serialize(null), + 'timeout' => $expected->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $actual = $driver->getMultipleTtl($keys); + + foreach ($actual as $key => $time) { + $this->assertContains($key, $keys); + + $expectedForKey = $key === $keys[0] ? $expected : null; + $this->assertEquals($expectedForKey, $time); + } + } + + #[DataProvider('serializersDataProvider')] + public function testTtlWithInvalidResponseKey(SerializerInterface $serializer): void + { + $driver = $this->cache([ + 'kv.TTL' => fn () => $this->response([ + new Item([ + 'key' => $this->randomString(), + 'value' => $serializer->serialize(null), + 'timeout' => $this->now()->format(\DateTimeInterface::RFC3339), + ]), + ]), + ], $serializer); + + $this->assertNull($driver->getTtl('__invalid__')); + } + + #[DataProvider('methodsDataProvider')] + public function testBadStorageNameOnAnyMethodExecution(callable $handler): void + { + // When RPC ServiceException like + $error = function () { + throw new ServiceException('no such storage "' . $this->name . '"'); + }; + + // Then expects message like that cache storage has not been defined + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + \sprintf( + 'Storage "%s" has not been defined. Please make sure your ' . + 'RoadRunner "kv" configuration contains a storage key named "%1$s"', + $this->name, + ), + ); + + $driver = $this->cache([ + 'kv.Has' => $error, + 'kv.Set' => $error, + 'kv.MGet' => $error, + 'kv.MExpire' => $error, + 'kv.TTL' => $error, + 'kv.Delete' => $error, + ]); + + $result = $handler($driver); + + // When the generator returns, then no error occurs + if ($result instanceof \Generator) { + \iterator_to_array($result); + } + } + + public function testTtlNotAvailable(): void + { + // When RPC ServiceException like + $error = function () { + throw new ServiceException('memcached_plugin_ttl: ttl not available'); + }; + + // Then expects message like that TTL not available + $this->expectException(NotImplementedException::class); + $this->expectExceptionMessage( + \sprintf( + 'Storage "%s" does not support kv.TTL RPC method execution. Please ' . + 'use another driver for the storage if you require this functionality', + $this->name, + ), + ); + + $driver = $this->cache(['kv.TTL' => $error]); + + $driver->getTtl('key'); + } + + #[DataProvider('serializersDataProvider')] + public function testGet(SerializerInterface $serializer): void + { + $expected = $this->randomString(1024); + + $driver = $this->cache([ + 'kv.MGet' => $this->response([ + new Item(['key' => 'key', 'value' => $serializer->serialize($expected)]), + ]), + ], $serializer); + + $this->assertSame($expected, $driver->get('key')); + } + + public function testGetWhenValueNotExists(): void + { + $driver = $this->cache(['kv.MGet' => $this->response()]); + + $this->assertNull($driver->get('key')); + } + + public function testGetDefaultWhenValueNotExists(): void + { + $expected = $this->randomString(); + + $driver = $this->cache(['kv.MGet' => $this->response()]); + + $this->assertSame($expected, $driver->get('key', $expected)); + } + + #[DataProvider('serializersDataProvider')] + public function testGetMultiple(SerializerInterface $serializer): void + { + $expected = [ + 'key0' => $this->randomString(), + 'key1' => $this->randomString(), + 'key2' => null, + 'key3' => null, + ]; + + $driver = $this->cache([ + // Only 2 items of 4 should be returned + 'kv.MGet' => $this->response([ + new Item(['key' => 'key0', 'value' => $serializer->serialize($expected['key0'])]), + new Item(['key' => 'key1', 'value' => $serializer->serialize($expected['key1'])]), + ]), + ], $serializer); + + $actual = $driver->getMultiple(\array_keys($expected)); + + $this->assertSame($expected, \iterator_to_array($actual)); + } + + #[DataProvider('serializersDataProvider')] + public function testHas(SerializerInterface $serializer): void + { + $key = $this->randomString(); + + $driver = $this->cache([ + 'kv.Has' => $this->response([ + new Item(['key' => $key, 'value' => $serializer->serialize(null)]), + ]), + ], $serializer); + + $this->assertTrue($driver->has($key)); + } + + #[DataProvider('serializersDataProvider')] + public function testHasWhenNotExists(SerializerInterface $serializer): void + { + $key = $this->randomString(); + + $driver = $this->cache([ + 'kv.Has' => $this->response(), + ], $serializer); + + $this->assertFalse($driver->has($key)); + } + + #[DataProvider('serializersDataProvider')] + public function testHasWithInvalidResponse(SerializerInterface $serializer): void + { + $key = $this->randomString(); + + $driver = $this->cache([ + 'kv.Has' => $this->response([ + new Item(['key' => $key, 'value' => $serializer->serialize(null)]), + ]), + ], $serializer); + + $this->assertFalse($driver->has('__invalid_key__')); + } + + public function testClear(): void + { + $driver = $this->cache(['kv.Clear' => $this->response()]); + + $result = $driver->clear(); + + $this->assertTrue($result); + } + + public function testClearError(): void + { + $this->expectException(KeyValueException::class); + $this->expectExceptionMessage('Something went wrong'); + + $driver = $this->cache([ + 'kv.Clear' => function () { + throw new ServiceException('Something went wrong'); + }, + ]); + + $driver->clear(); + } + + public function testClearMethodNotFoundError(): void + { + $this->expectException(KeyValueException::class); + $this->expectExceptionMessage( + 'RoadRunner does not support kv.Clear RPC method. ' . + 'Please make sure you are using RoadRunner v2.3.1 or higher.', + ); + + $driver = $this->cache(); + $driver->clear(); + } + + #[DataProvider('serializersWithValuesDataProvider')] + public function testSet(SerializerInterface $serializer, $expected): void + { + if (\is_float($expected) && \is_nan($expected)) { + $this->markTestSkipped('Unable to execute test for NAN float value'); + } + + if (\is_resource($expected)) { + $this->markTestSkipped('Unable to execute test for resource value'); + } + + $driver = $this->getAssertableCacheOnSet($serializer, ['key' => $expected]); + + $driver->set('key', $expected); + } + + #[DataProvider('serializersWithValuesDataProvider')] + public function testMultipleSet(SerializerInterface $serializer, $value): void + { + if (\is_float($value) && \is_nan($value)) { + $this->markTestSkipped('Unable to execute test for NAN float value'); + } + + if (\is_resource($value)) { + $this->markTestSkipped('Unable to execute test for resource value'); + } + + $expected = ['key' => $value, 'key2' => $value]; + + $driver = $this->getAssertableCacheOnSet($serializer, $expected); + $driver->setMultiple($expected); + } + + public function testSetWithRelativeIntTTL(): void + { + $seconds = 0xDEAD_BEEF; + + // This is the current time for cache and relative date + $now = new \DateTimeImmutable(); + // Relative date: [$now] + [$seconds] + $expected = $now->add(new \DateInterval("PT{$seconds}S")) + ->format(\DateTimeInterface::RFC3339); + + $driver = $this->frozenDateCache($now, [ + 'kv.Set' => function (Request $request) use ($expected) { + /** @var Item $item */ + $item = $request->getItems()[0]; + $this->assertSame($expected, $item->getTimeout()); + + return $this->response(); + }, + ]); + + // Send relative date in $now + $seconds + $driver->set('key', 'value', $seconds); + } + + public function testSetWithRelativeDateIntervalTTL(): void + { + $seconds = 0xDEAD_BEEF; + $interval = new \DateInterval("PT{$seconds}S"); + + // This is the current time for cache and relative date + $now = new \DateTimeImmutable(); + + // Add interval to frozen current time + $expected = $now->add($interval) + ->format(\DateTimeInterface::RFC3339); + + $driver = $this->frozenDateCache($now, [ + 'kv.Set' => function (Request $request) use ($expected) { + /** @var Item $item */ + $item = $request->getItems()[0]; + $this->assertSame($expected, $item->getTimeout()); + + return $this->response(); + }, + ]); + + $driver->set('key', 'value', $interval); + } + + #[DataProvider('valuesDataProvider')] + public function testSetWithInvalidTTL(mixed $invalidTTL): void + { + $type = \get_debug_type($invalidTTL); + + if ($invalidTTL === null || \is_int($invalidTTL) || $invalidTTL instanceof \DateTimeInterface) { + $this->markTestSkipped('Can not complete negative test for valid TTL of type ' . $type); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Cache item ttl (expiration) must be of type int or \DateInterval, but ' . $type . ' passed', + ); + + $driver = $this->cache(); + + // Send relative date in $now + $seconds + $driver->set('key', 'value', $invalidTTL); + } + + public function testDelete(): void + { + $driver = $this->cache(['kv.Delete' => $this->response([])]); + $this->assertTrue($driver->delete('key')); + } + + public function testDeleteWithError(): void + { + $this->expectException(KeyValueException::class); + + $driver = $this->cache([ + 'kv.Delete' => function () { + throw new ServiceException('Error: Can not delete something'); + }, + ]); + + $driver->delete('key'); + } + + public function testDeleteMultiple(): void + { + $driver = $this->cache(['kv.Delete' => $this->response([])]); + $this->assertTrue($driver->deleteMultiple(['key', 'key2'])); + } + + public function testDeleteMultipleWithError(): void + { + $this->expectException(KeyValueException::class); + + $driver = $this->cache([ + 'kv.Delete' => function () { + throw new ServiceException('Error: Can not delete something'); + }, + ]); + + $driver->deleteMultiple(['key', 'key2']); + } + + public function testGetMultipleWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + foreach ($driver->getMultiple([0 => 0xDEAD_BEEF]) as $_) { + // + } + } + + public function testSetMultipleWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + $driver->setMultiple([0 => 0xDEAD_BEEF]); + } + + public function testDeleteMultipleWithInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache key must be a string, but int passed'); + + $driver = $this->cache(); + $driver->deleteMultiple([0 => 0xDEAD_BEEF]); + } + + public function testImmutableWhileSwitchSerialization(): void + { + $expected = $this->randomString(1024); + + $driver = $this->cache([ + 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), + ], new RawSerializerStub()); + + $decorated = $driver->withSerializer(new DefaultSerializer()); + + // Behaviour MUST NOT be changed + $this->assertSame($expected, $driver->get('key')); + } + + public function testErrorOnInvalidSerialization(): void + { + $this->expectException(SerializationException::class); + + $expected = $this->randomString(1024); + + $driver = $this->cache([ + 'kv.MGet' => $this->response([new Item(['key' => 'key', 'value' => $expected])]), + ], new RawSerializerStub()); + + $actual = $driver->withSerializer(new DefaultSerializer()) + ->get('key'); + } + + /** + * @return \Traversable + */ + public static function methodsDataProvider(): \Traversable + { + yield 'getTtl' => [fn (Cache $c) => $c->getTtl('key')]; + yield 'getMultipleTtl' => [fn (Cache $c) => $c->getMultipleTtl(['key'])]; + yield 'get' => [fn (Cache $c) => $c->get('key')]; + yield 'set' => [fn (Cache $c) => $c->set('key', 'value')]; + yield 'getMultiple' => [fn (Cache $c) => $c->getMultiple(['key'])]; + yield 'setMultiple' => [fn (Cache $c) => $c->setMultiple(['key' => 'value'])]; + yield 'deleteMultiple' => [fn (Cache $c) => $c->deleteMultiple(['key'])]; + yield 'delete' => [fn (Cache $c) => $c->delete('key')]; + yield 'has' => [fn (Cache $c) => $c->has('key')]; + } + + public static function serializersWithValuesDataProvider(): array + { + $result = []; + + foreach (self::serializersDataProvider() as $name => [$serializer]) { + foreach (self::valuesDataProvider() as $type => [$value]) { + $result['[' . $type . '] using [' . $name . ']'] = [$serializer, $value]; + } + } + + return $result; + } + + /** + * @return array + * @throws \SodiumException + */ + public static function serializersDataProvider(): array + { + $result = []; + $result['PHP Serialize'] = [new DefaultSerializer()]; + + // ext-igbinary required for this serializer + if (\extension_loaded('igbinary')) { + $result['Igbinary'] = [new IgbinarySerializer()]; + } + + // ext-sodium required for this serialize + if (\extension_loaded('sodium')) { + foreach ($result as $name => [$serializer]) { + $result['Sodium through ' . $name] = [ + new SodiumSerializer($serializer, \sodium_crypto_box_keypair()), + ]; + } + } + + return $result; + } + + protected function randomString(int $len = 32): string + { + return \bin2hex(\random_bytes($len)); + } + + /** + * Returns normalized datetime without milliseconds + */ + protected function now(): \DateTimeInterface + { + $time = (new \DateTime())->format(\DateTimeInterface::RFC3339); + + return \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $time); + } + + /** + * @param array $items + */ + protected function response(array $items = []): string + { + return (new Response(['items' => $items]))->serializeToString(); + } + + protected function getAssertableCacheOnSet(SerializerInterface $serializer, array $expected): StorageInterface + { + return $this->cache([ + 'kv.Set' => function (Request $request) use ($serializer, $expected): string { + $items = $request->getItems(); + + $result = []; + + /** @var Item $item */ + foreach ($items as $item) { + $result[] = $item; + + $this->assertArrayHasKey($item->getKey(), $expected); + $this->assertEquals($expected[$item->getKey()], $serializer->unserialize($item->getValue())); + } + + $this->assertSame($items->count(), \count($expected)); + + return $this->response($result); + }, + ], $serializer); + } +} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index efc3f1a..daa73ed 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -8,23 +8,26 @@ use Spiral\RoadRunner\KeyValue\FactoryInterface; use Spiral\RoadRunner\KeyValue\Serializer\DefaultSerializer; use Spiral\RoadRunner\KeyValue\Serializer\SerializerInterface; -use function random_bytes; -class FactoryTest extends TestCase +final class FactoryTest extends TestCase { /** * @param array $mapping */ - private function factory(array $mapping = [], SerializerInterface $serializer = new DefaultSerializer()): FactoryInterface - { + private function factory( + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer(), + ): FactoryInterface { return new Factory($this->rpc($mapping), $serializer); } /** * @param array $mapping */ - private function asyncFactory(array $mapping = [], SerializerInterface $serializer = new DefaultSerializer()): FactoryInterface - { + private function asyncFactory( + array $mapping = [], + SerializerInterface $serializer = new DefaultSerializer() + ): FactoryInterface { return new Factory($this->asyncRPC($mapping), $serializer); } @@ -42,20 +45,18 @@ public function testAsyncFactoryCreation(): void public function testSuccessSelectOfUnknownStorage(): void { - $name = random_bytes(32); + $name = \random_bytes(32); - $driver = $this->factory() - ->select($name); + $driver = $this->factory()->select($name); $this->assertSame($name, $driver->getName()); } public function testSuccessSelectOfUnknownStorageWithAsync(): void { - $name = random_bytes(32); + $name = \random_bytes(32); - $driver = $this->asyncFactory() - ->select($name); + $driver = $this->asyncFactory()->select($name); $this->assertSame($name, $driver->getName()); } diff --git a/tests/Stub/AsyncRPCConnectionStub.php b/tests/Stub/AsyncRPCConnectionStub.php index 096e766..3ed205f 100644 --- a/tests/Stub/AsyncRPCConnectionStub.php +++ b/tests/Stub/AsyncRPCConnectionStub.php @@ -1,5 +1,7 @@ $mapping - * @return RPCConnectionStub */ protected function rpc(array $mapping = []): RPCConnectionStub { @@ -21,7 +20,6 @@ protected function rpc(array $mapping = []): RPCConnectionStub /** * @param array $mapping - * @return AsyncRPCConnectionStub */ protected function asyncRPC(array $mapping = []): AsyncRPCConnectionStub {