diff --git a/CHANGELOG.md b/CHANGELOG.md index 843aef8..9af4bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Latest ------ ### Changes +* [#20](https://github.com/cleverage/cache-process-bundle/issues/20) Add missing tests: GetTask and SetTask (options validation at initialization, context, missing adapter, stored `null`, overwriting), custom tasks extending AbstractCacheTask, Adapter, bundle and DI extension. * [#25](https://github.com/cleverage/cache-process-bundle/issues/25) Give the ids of both services in the error on duplicate adapter codes: the adapters are registered by a compiler pass of the bundle, `AdapterRegistry::addAdapter()` gets an optional `$serviceId` argument. Update documentation, add tests. ### Fixes diff --git a/tests/Adapter/AdapterTest.php b/tests/Adapter/AdapterTest.php new file mode 100644 index 0000000..1c2589a --- /dev/null +++ b/tests/Adapter/AdapterTest.php @@ -0,0 +1,93 @@ +pool = new ArrayAdapter(); + $this->adapter = new Adapter($this->pool, 'memory'); + } + + public function testGetCode(): void + { + self::assertSame('memory', $this->adapter->getCode()); + } + + public function testSaveAndGetItem(): void + { + self::assertFalse($this->adapter->getItem('key1')->isHit()); + + self::assertTrue($this->adapter->save($this->adapter->getItem('key1')->set('value1'))); + + self::assertTrue($this->adapter->hasItem('key1')); + self::assertSame('value1', $this->adapter->getItem('key1')->get()); + // Stored in the decorated pool + self::assertSame('value1', $this->pool->getItem('key1')->get()); + } + + public function testGetItems(): void + { + $this->pool->save($this->pool->getItem('key1')->set('value1')); + + $values = []; + foreach ($this->adapter->getItems(['key1', 'key2']) as $key => $item) { + $values[$key] = $item->get(); + } + + self::assertSame(['key1' => 'value1', 'key2' => null], $values); + } + + public function testSaveDeferredAndCommit(): void + { + self::assertTrue($this->adapter->saveDeferred($this->adapter->getItem('key1')->set('value1'))); + self::assertTrue($this->adapter->commit()); + + self::assertSame('value1', $this->pool->getItem('key1')->get()); + } + + public function testDeleteItems(): void + { + foreach (['key1', 'key2', 'key3'] as $key) { + $this->pool->save($this->pool->getItem($key)->set($key)); + } + + self::assertTrue($this->adapter->deleteItem('key1')); + self::assertTrue($this->adapter->deleteItems(['key2'])); + + self::assertFalse($this->pool->hasItem('key1')); + self::assertFalse($this->pool->hasItem('key2')); + self::assertTrue($this->pool->hasItem('key3')); + } + + public function testClear(): void + { + $this->pool->save($this->pool->getItem('key1')->set('value1')); + + self::assertTrue($this->adapter->clear()); + + self::assertFalse($this->pool->hasItem('key1')); + } +} diff --git a/tests/CleverAgeCacheProcessBundleTest.php b/tests/CleverAgeCacheProcessBundleTest.php new file mode 100644 index 0000000..14fc169 --- /dev/null +++ b/tests/CleverAgeCacheProcessBundleTest.php @@ -0,0 +1,44 @@ +getPath(); + + self::assertSame(\dirname(__DIR__), $path); + self::assertDirectoryExists($path.'/config/services'); + } + + public function testAdaptersPassIsRegistered(): void + { + $container = new ContainerBuilder(); + (new CleverAgeCacheProcessBundle())->build($container); + + $passes = array_filter( + $container->getCompilerPassConfig()->getBeforeOptimizationPasses(), + static fn (object $pass): bool => $pass instanceof RegisterAdaptersPass + ); + self::assertCount(1, $passes); + } +} diff --git a/tests/DependencyInjection/CleverAgeCacheProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeCacheProcessExtensionTest.php new file mode 100644 index 0000000..fc5b101 --- /dev/null +++ b/tests/DependencyInjection/CleverAgeCacheProcessExtensionTest.php @@ -0,0 +1,68 @@ +load([], $container); + + $definition = $container->getDefinition('cleverage_cache_process.registry.adapter'); + self::assertSame(AdapterRegistry::class, $definition->getClass()); + self::assertTrue($definition->isShared()); + } + + /** + * @return iterable + */ + public static function provideTasks(): iterable + { + yield 'get' => ['cleverage_cache_process.task.get', GetTask::class]; + yield 'set' => ['cleverage_cache_process.task.set', SetTask::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('provideTasks')] + public function testTaskIsRegistered(string $id, string $class): void + { + $container = new ContainerBuilder(); + (new CleverAgeCacheProcessExtension())->load([], $container); + + $definition = $container->getDefinition($id); + self::assertSame($class, $definition->getClass()); + // Tasks are stateful: each process execution must get its own instance + self::assertFalse($definition->isShared()); + self::assertEquals([new Reference('cleverage_cache_process.registry.adapter')], $definition->getArguments()); + + // Referenced as '@' in process configurations + $alias = $container->getAlias($class); + self::assertSame($id, (string) $alias); + self::assertTrue($alias->isPublic()); + } +} diff --git a/tests/Task/AbstractCacheTaskTest.php b/tests/Task/AbstractCacheTaskTest.php new file mode 100644 index 0000000..f8307d8 --- /dev/null +++ b/tests/Task/AbstractCacheTaskTest.php @@ -0,0 +1,96 @@ +createTask(['adapter' => 'memory', 'key' => 'key1']); + + // Resolved options: the order of the keys is not relevant + self::assertEquals( + ['adapter' => 'memory', 'key' => 'key2', 'ttl' => 60], + $this->execute($task, $state, ['key' => 'key2', 'ttl' => 60, 'sku' => 'ignored']) + ); + self::assertEquals(['adapter' => 'memory', 'key' => 'key1', 'ttl' => null], $this->execute($task, $state, null)); + } + + public function testCustomOptionFromInputIsValidated(): void + { + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => 'key1']); + + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The option "ttl" with value "60" is expected to be of type "int" or "null", but is of type "string".'); + $this->execute($task, $state, ['ttl' => '60']); + } + + /** + * @param array $options + * + * @return array{AbstractCacheTask, ProcessState} + */ + private function createTask(array $options): array + { + $processConfiguration = new ProcessConfiguration('test', []); + $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration)); + $state->setContextualOptionResolver(new ContextualOptionResolver()); + $state->setContext([]); + $state->setTaskConfiguration(new TaskConfiguration('custom', AbstractCacheTask::class, $options)); + + $task = new class(new AdapterRegistry()) extends AbstractCacheTask { + public function execute(ProcessState $state): void + { + $state->setOutput($this->getMergedOptions($state)); + } + + #[\Override] + protected function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefault('ttl', null); + $resolver->setAllowedTypes('ttl', ['int', 'null']); + } + }; + $task->initialize($state); + + return [$task, $state]; + } + + private function execute(AbstractCacheTask $task, ProcessState $state, mixed $input): mixed + { + $state->reset(false); + $state->setInput($input); + $task->execute($state); + + return $state->getOutput(); + } +} diff --git a/tests/Task/GetTaskTest.php b/tests/Task/GetTaskTest.php index 5942993..fcc498c 100644 --- a/tests/Task/GetTaskTest.php +++ b/tests/Task/GetTaskTest.php @@ -14,6 +14,7 @@ namespace CleverAge\CacheProcessBundle\Tests\Task; use CleverAge\CacheProcessBundle\Adapter\Adapter; +use CleverAge\CacheProcessBundle\Exception\MissingAdapterException; use CleverAge\CacheProcessBundle\Registry\AdapterRegistry; use CleverAge\CacheProcessBundle\Task\GetTask; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; @@ -26,10 +27,13 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; #[CoversClass(GetTask::class)] #[UsesClass(Adapter::class)] #[UsesClass(AdapterRegistry::class)] +#[UsesClass(MissingAdapterException::class)] class GetTaskTest extends TestCase { private Adapter $adapter; @@ -81,17 +85,76 @@ public function testInputValuesAreValidated(): void $this->execute($task, $state, ['key' => 1]); } + public function testGetStoredNullValue(): void + { + $this->adapter->save($this->adapter->getItem('null')->set(null)); + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => 'null']); + + self::assertNull($this->execute($task, $state, null)); + } + + public function testKeyFromContext(): void + { + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => '{{ sku }}'], ['sku' => 'key2']); + + self::assertSame('value2', $this->execute($task, $state, null)); + } + + public function testEmptyArrayInputUsesOptions(): void + { + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => 'key1']); + + self::assertSame('value1', $this->execute($task, $state, [])); + } + + public function testInputOverridesAdapter(): void + { + [$task, $state] = $this->createTask(['adapter' => 'other', 'key' => 'key1']); + + self::assertSame('value1', $this->execute($task, $state, ['adapter' => 'memory'])); + } + + public function testMissingAdapter(): void + { + [$task, $state] = $this->createTask(['adapter' => 'missing', 'key' => 'key1']); + + $this->expectException(MissingAdapterException::class); + $this->expectExceptionMessage('Adapter missing is missing'); + $this->execute($task, $state, null); + } + + public function testRequiredOptionsAtInitialization(): void + { + $this->expectException(MissingOptionsException::class); + $this->expectExceptionMessage('The required option "key" is missing.'); + $this->createTask(['adapter' => 'memory']); + } + + public function testUndefinedOptionAtInitialization(): void + { + $this->expectException(UndefinedOptionsException::class); + $this->createTask(['adapter' => 'memory', 'key' => 'key1', 'value' => 'value1']); + } + + public function testInvalidOptionTypeAtInitialization(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The option "adapter" with value 1 is expected to be of type "string", but is of type "int".'); + $this->createTask(['adapter' => 1, 'key' => 'key1']); + } + /** * @param array $options + * @param array $context * * @return array{GetTask, ProcessState} */ - private function createTask(array $options): array + private function createTask(array $options, array $context = []): array { $processConfiguration = new ProcessConfiguration('test', []); $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration)); $state->setContextualOptionResolver(new ContextualOptionResolver()); - $state->setContext([]); + $state->setContext($context); $state->setTaskConfiguration(new TaskConfiguration('get', GetTask::class, $options)); $registry = new AdapterRegistry(); diff --git a/tests/Task/SetTaskTest.php b/tests/Task/SetTaskTest.php index 51f9654..c2f87f7 100644 --- a/tests/Task/SetTaskTest.php +++ b/tests/Task/SetTaskTest.php @@ -14,6 +14,7 @@ namespace CleverAge\CacheProcessBundle\Tests\Task; use CleverAge\CacheProcessBundle\Adapter\Adapter; +use CleverAge\CacheProcessBundle\Exception\MissingAdapterException; use CleverAge\CacheProcessBundle\Registry\AdapterRegistry; use CleverAge\CacheProcessBundle\Task\SetTask; use CleverAge\ProcessBundle\Configuration\ProcessConfiguration; @@ -26,10 +27,13 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; #[CoversClass(SetTask::class)] #[UsesClass(Adapter::class)] #[UsesClass(AdapterRegistry::class)] +#[UsesClass(MissingAdapterException::class)] class SetTaskTest extends TestCase { private Adapter $adapter; @@ -77,17 +81,78 @@ public function testInputValuesAreValidated(): void $this->execute($task, $state, ['adapter' => 1, 'key' => 'key1', 'value' => 'value1']); } + public function testNoOutput(): void + { + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => 'key1', 'value' => 'value1']); + + $this->execute($task, $state, ['sku' => 'ABC-001']); + + self::assertNull($state->getOutput()); + } + + public function testExistingItemIsOverwritten(): void + { + $this->adapter->save($this->adapter->getItem('key1')->set('old')); + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => 'key1', 'value' => 'new']); + + $this->execute($task, $state, null); + + self::assertSame('new', $this->adapter->getItem('key1')->get()); + } + + public function testNullValueIsStored(): void + { + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => 'key1', 'value' => null]); + + $this->execute($task, $state, null); + + self::assertTrue($this->adapter->hasItem('key1')); + self::assertNull($this->adapter->getItem('key1')->get()); + } + + public function testKeyFromContext(): void + { + [$task, $state] = $this->createTask(['adapter' => 'memory', 'key' => '{{ sku }}', 'value' => 'value1'], ['sku' => 'ABC-001']); + + $this->execute($task, $state, null); + + self::assertSame('value1', $this->adapter->getItem('ABC-001')->get()); + } + + public function testMissingAdapter(): void + { + [$task, $state] = $this->createTask(['adapter' => 'missing', 'key' => 'key1', 'value' => 'value1']); + + $this->expectException(MissingAdapterException::class); + $this->expectExceptionMessage('Adapter missing is missing'); + $this->execute($task, $state, null); + } + + public function testRequiredOptionsAtInitialization(): void + { + $this->expectException(MissingOptionsException::class); + $this->expectExceptionMessage('The required option "value" is missing.'); + $this->createTask(['adapter' => 'memory', 'key' => 'key1']); + } + + public function testUndefinedOptionAtInitialization(): void + { + $this->expectException(UndefinedOptionsException::class); + $this->createTask(['adapter' => 'memory', 'key' => 'key1', 'value' => 'value1', 'ttl' => 60]); + } + /** * @param array $options + * @param array $context * * @return array{SetTask, ProcessState} */ - private function createTask(array $options): array + private function createTask(array $options, array $context = []): array { $processConfiguration = new ProcessConfiguration('test', []); $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration)); $state->setContextualOptionResolver(new ContextualOptionResolver()); - $state->setContext([]); + $state->setContext($context); $state->setTaskConfiguration(new TaskConfiguration('set', SetTask::class, $options)); $registry = new AdapterRegistry();