From c583cebf359cab2a550bbdbc65fa1c8262023c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 24 Aug 2026 11:47:39 +0200 Subject: [PATCH 1/6] test(cache-pool): assert clear() does not affect another namespace Add an optional createCachePoolWithOtherNamespace() extension point and a test that saves the same key in two pools sharing the same storage under different namespaces. The test is skipped when the implementation returns null. --- src/CachePoolTest.php | 36 ++++++++++++++++++++++++++++++++++++ tests/CachePoolTest.php | 9 +++++++++ 2 files changed, 45 insertions(+) diff --git a/src/CachePoolTest.php b/src/CachePoolTest.php index 217a2f8..03948a3 100644 --- a/src/CachePoolTest.php +++ b/src/CachePoolTest.php @@ -34,6 +34,16 @@ abstract class CachePoolTest extends TestCase */ abstract public function createCachePool(): CacheItemPoolInterface; + /** + * A second pool backed by the same storage as createCachePool(), under a different namespace. + * + * Return null when the implementation cannot provide one, the test is then skipped. + */ + public function createCachePoolWithOtherNamespace(): ?CacheItemPoolInterface + { + return null; + } + #[Before] public function setupService() { @@ -318,6 +328,32 @@ public function testClearWithDeferredItems() $this->assertFalse($this->cache->getItem('key')->isHit(), 'Deferred items must be cleared on clear(). '); } + public function testClearDoesNotAffectOtherNamespaces() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + } + + $other = $this->createCachePoolWithOtherNamespace(); + + if (null === $other) { + $this->markTestSkipped('This implementation cannot provide a second pool on the same storage with another namespace.'); + } + + $this->cache->save($this->cache->getItem('key')->set('value')); + $other->save($other->getItem('key')->set('other value')); + + $this->assertTrue($this->cache->clear(), 'clear() must return true if cache was cleared. '); + $this->assertFalse($this->cache->hasItem('key'), 'The cleared pool should be empty after it is cleared.'); + + $otherItem = $other->getItem('key'); + $this->assertTrue($otherItem->isHit(), 'Clearing a pool must not clear a pool using another namespace on the same storage.'); + $this->assertSame('other value', $otherItem->get(), 'The item stored in the other namespace must keep its own value.'); + + // The tearDownService() hook only clears $this->cache, so the other pool is cleared here. + $other->clear(); + } + public function testDeleteItem() { if (isset($this->skippedTests[__FUNCTION__])) { diff --git a/tests/CachePoolTest.php b/tests/CachePoolTest.php index 9586745..dda4adf 100644 --- a/tests/CachePoolTest.php +++ b/tests/CachePoolTest.php @@ -25,6 +25,8 @@ final class CachePoolTest extends CachePoolContract { private ?string $namespace = null; + private ?string $otherNamespace = null; + public function createCachePool(): CacheItemPoolInterface { $this->namespace ??= bin2hex(random_bytes(16)); @@ -32,6 +34,13 @@ public function createCachePool(): CacheItemPoolInterface return new ValidatingCachePool(new FilesystemAdapter($this->namespace)); } + public function createCachePoolWithOtherNamespace(): CacheItemPoolInterface + { + $this->otherNamespace ??= bin2hex(random_bytes(16)); + + return new ValidatingCachePool(new FilesystemAdapter($this->otherNamespace)); + } + public function testDataProvidersExposeCases() { self::assertNotEmpty(self::invalidKeys()); From 8d28b46262d0383121d8d136fa78ceaba362191f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 24 Aug 2026 11:47:49 +0200 Subject: [PATCH 2/6] test(taggable): make testInvalidateTags discriminating The test used overlapping tags, so an implementation that only invalidates the first tag of the list still passed. Use disjoint tags plus a control item tagged with none of the invalidated tags. --- src/TaggableCachePoolTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/TaggableCachePoolTest.php b/src/TaggableCachePoolTest.php index 3e9216e..c275239 100644 --- a/src/TaggableCachePoolTest.php +++ b/src/TaggableCachePoolTest.php @@ -287,15 +287,19 @@ public function testInvalidateTags() } $item = $this->cache->getItem('key')->set('value'); - $item->setTags(['tag1', 'tag2']); + $item->setTags(['tag1']); $this->cache->save($item); $item = $this->cache->getItem('key2')->set('value'); - $item->setTags(['tag1']); + $item->setTags(['tag2']); + $this->cache->save($item); + $item = $this->cache->getItem('key3')->set('value'); + $item->setTags(['tag3']); $this->cache->save($item); $this->cache->invalidateTags(['tag1', 'tag2']); $this->assertFalse($this->cache->hasItem('key'), 'Item should be cleared when tag is invalidated'); $this->assertFalse($this->cache->hasItem('key2'), 'Item should be cleared when tag is invalidated'); + $this->assertTrue($this->cache->hasItem('key3'), 'An item tagged with none of the invalidated tags must survive'); // Create a new item (no tags) $item = $this->cache->getItem('key')->set('value'); From e61cecb0e6b79c9291269cede4a1700a6e03c76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 24 Aug 2026 11:47:49 +0200 Subject: [PATCH 3/6] test(taggable): assert deleteItem() returns true for a tagged item The return value was ignored, so an implementation returning false while the deletion actually happened still passed. --- src/TaggableCachePoolTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TaggableCachePoolTest.php b/src/TaggableCachePoolTest.php index c275239..7bad680 100644 --- a/src/TaggableCachePoolTest.php +++ b/src/TaggableCachePoolTest.php @@ -221,7 +221,7 @@ public function testRemoveTagWhenItemIsRemoved() // Save the item and then delete it $this->cache->save($item); - $this->cache->deleteItem('key'); + $this->assertTrue($this->cache->deleteItem('key'), 'deleteItem() must return true when a tagged item is deleted'); // Create a new item (same key) (no tags) $item = $this->cache->getItem('key')->set('value'); From 584da2c776ccc98596fc1b8ea62e17d8b1f55893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 3 Sep 2026 21:06:16 +0200 Subject: [PATCH 4/6] test(cache-pool): clean up the other pool even when an assertion fails The final $other->clear() only ran when every assertion above it passed, so a failure left the second namespace populated for the next test. --- src/CachePoolTest.php | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/CachePoolTest.php b/src/CachePoolTest.php index 03948a3..15b121e 100644 --- a/src/CachePoolTest.php +++ b/src/CachePoolTest.php @@ -340,18 +340,20 @@ public function testClearDoesNotAffectOtherNamespaces() $this->markTestSkipped('This implementation cannot provide a second pool on the same storage with another namespace.'); } - $this->cache->save($this->cache->getItem('key')->set('value')); - $other->save($other->getItem('key')->set('other value')); - - $this->assertTrue($this->cache->clear(), 'clear() must return true if cache was cleared. '); - $this->assertFalse($this->cache->hasItem('key'), 'The cleared pool should be empty after it is cleared.'); + try { + $this->cache->save($this->cache->getItem('key')->set('value')); + $other->save($other->getItem('key')->set('other value')); - $otherItem = $other->getItem('key'); - $this->assertTrue($otherItem->isHit(), 'Clearing a pool must not clear a pool using another namespace on the same storage.'); - $this->assertSame('other value', $otherItem->get(), 'The item stored in the other namespace must keep its own value.'); + $this->assertTrue($this->cache->clear(), 'clear() must return true if cache was cleared. '); + $this->assertFalse($this->cache->hasItem('key'), 'The cleared pool should be empty after it is cleared.'); - // The tearDownService() hook only clears $this->cache, so the other pool is cleared here. - $other->clear(); + $otherItem = $other->getItem('key'); + $this->assertTrue($otherItem->isHit(), 'Clearing a pool must not clear a pool using another namespace on the same storage.'); + $this->assertSame('other value', $otherItem->get(), 'The item stored in the other namespace must keep its own value.'); + } finally { + // The tearDownService() hook only clears $this->cache, so the other pool is cleared here. + $other->clear(); + } } public function testDeleteItem() From b50747f2bd5758141dac16b6e8076485946b8244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 3 Sep 2026 21:06:43 +0200 Subject: [PATCH 5/6] test(taggable): keep an item matched by two invalidated tags key carries tag1 and tag2 again, which is the only place in the suite where one invalidateTags() call matches an item through two of the passed tags. key2 carries tag2 alone and key3 carries tag1 alone, so each invalidated tag is still required on its own. key4 carries none of them and checks that the invalidation does not reach too far. --- src/TaggableCachePoolTest.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/TaggableCachePoolTest.php b/src/TaggableCachePoolTest.php index 7bad680..f3cbab9 100644 --- a/src/TaggableCachePoolTest.php +++ b/src/TaggableCachePoolTest.php @@ -286,20 +286,28 @@ public function testInvalidateTags() $this->markTestSkipped($this->skippedTests[__FUNCTION__]); } + // Matched through both invalidated tags at once. $item = $this->cache->getItem('key')->set('value'); - $item->setTags(['tag1']); + $item->setTags(['tag1', 'tag2']); $this->cache->save($item); + // Matched through tag2 only, so tag2 must be handled on its own. $item = $this->cache->getItem('key2')->set('value'); $item->setTags(['tag2']); $this->cache->save($item); + // Matched through tag1 only, so tag1 must be handled on its own. $item = $this->cache->getItem('key3')->set('value'); + $item->setTags(['tag1']); + $this->cache->save($item); + // Matched by none of the invalidated tags. + $item = $this->cache->getItem('key4')->set('value'); $item->setTags(['tag3']); $this->cache->save($item); $this->cache->invalidateTags(['tag1', 'tag2']); $this->assertFalse($this->cache->hasItem('key'), 'Item should be cleared when tag is invalidated'); $this->assertFalse($this->cache->hasItem('key2'), 'Item should be cleared when tag is invalidated'); - $this->assertTrue($this->cache->hasItem('key3'), 'An item tagged with none of the invalidated tags must survive'); + $this->assertFalse($this->cache->hasItem('key3'), 'Item should be cleared when tag is invalidated'); + $this->assertTrue($this->cache->hasItem('key4'), 'An item tagged with none of the invalidated tags must survive'); // Create a new item (no tags) $item = $this->cache->getItem('key')->set('value'); From 6336caf6389f159ed901071a83c23313f139a7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 3 Sep 2026 21:06:44 +0200 Subject: [PATCH 6/6] test(taggable): assert invalidateTags() does not cross namespaces TaggableCachePoolTest gains the same optional createCachePoolWithOtherNamespace() as CachePoolTest, returning null by default so the test is skipped on implementations that cannot provide a second pool. The TaggableCachePool fixture now takes an optional shared storage and a namespace, and wraps the storage in a ProxyAdapter. Both pools then sit on one key space where the namespace is a real key prefix, so the test can detect a tag invalidation that reaches across namespaces. --- src/TaggableCachePoolTest.php | 38 ++++++++++++++++++++++++++++ tests/Fixtures/TaggableCachePool.php | 12 ++++++--- tests/TaggableCachePoolTest.php | 15 ++++++++++- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/TaggableCachePoolTest.php b/src/TaggableCachePoolTest.php index f3cbab9..a82e8f7 100644 --- a/src/TaggableCachePoolTest.php +++ b/src/TaggableCachePoolTest.php @@ -34,6 +34,16 @@ abstract class TaggableCachePoolTest extends TestCase */ abstract public function createCachePool(): TaggableCacheItemPoolInterface; + /** + * A second pool backed by the same storage as createCachePool(), under a different namespace. + * + * Return null when the implementation cannot provide one, the test is then skipped. + */ + public function createCachePoolWithOtherNamespace(): ?TaggableCacheItemPoolInterface + { + return null; + } + #[Before] public function setupService() { @@ -317,6 +327,34 @@ public function testInvalidateTags() $this->assertTrue($this->cache->hasItem('key'), 'Item k list should be removed when clearing the tags'); } + public function testInvalidateTagsDoesNotAffectOtherNamespaces() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + } + + $other = $this->createCachePoolWithOtherNamespace(); + + if (null === $other) { + $this->markTestSkipped('This implementation cannot provide a second pool on the same storage with another namespace.'); + } + + try { + $this->cache->save($this->cache->getItem('key')->set('value')->setTags(['tag1'])); + $other->save($other->getItem('key')->set('other value')->setTags(['tag1'])); + + $this->assertTrue($this->cache->invalidateTags(['tag1'])); + $this->assertFalse($this->cache->hasItem('key'), 'The invalidated item should be gone from its own pool.'); + + $otherItem = $other->getItem('key'); + $this->assertTrue($otherItem->isHit(), 'Invalidating a tag must not reach items of a pool using another namespace on the same storage.'); + $this->assertSame('other value', $otherItem->get(), 'The item stored in the other namespace must keep its own value.'); + } finally { + // The tearDownService() hook only clears $this->cache, so the other pool is cleared here. + $other->clear(); + } + } + public function testInvalidateTagsValidatesEveryTagBeforeMutation() { if (isset($this->skippedTests[__FUNCTION__])) { diff --git a/tests/Fixtures/TaggableCachePool.php b/tests/Fixtures/TaggableCachePool.php index bffb7d7..093ee82 100644 --- a/tests/Fixtures/TaggableCachePool.php +++ b/tests/Fixtures/TaggableCachePool.php @@ -16,19 +16,25 @@ use Cache\TagInterop\TaggableCacheItemInterface; use Cache\TagInterop\TaggableCacheItemPoolInterface; use Psr\Cache\CacheItemInterface; +use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\ProxyAdapter; use Symfony\Component\Cache\Exception\InvalidArgumentException; final class TaggableCachePool implements TaggableCacheItemPoolInterface { - private readonly ArrayAdapter $pool; + private readonly AdapterInterface $pool; /** @var array> */ private array $tagsByKey = []; - public function __construct() + /** + * Passing a storage and a namespace puts several pools on one key space, where the + * namespace is a real key prefix, as a shared table or collection would. + */ + public function __construct(?AdapterInterface $storage = null, string $namespace = '') { - $this->pool = new ArrayAdapter(); + $this->pool = null === $storage ? new ArrayAdapter() : new ProxyAdapter($storage, $namespace); } public function getItem(string $key): TaggableCacheItemInterface diff --git a/tests/TaggableCachePoolTest.php b/tests/TaggableCachePoolTest.php index 34fa489..539f90c 100644 --- a/tests/TaggableCachePoolTest.php +++ b/tests/TaggableCachePoolTest.php @@ -17,12 +17,25 @@ use Cache\IntegrationTests\Tests\Fixtures\TaggableCachePool; use Cache\TagInterop\TaggableCacheItemPoolInterface; use PHPUnit\Framework\SkippedTest; +use Symfony\Component\Cache\Adapter\ArrayAdapter; final class TaggableCachePoolTest extends TaggableCachePoolContract { + private ?ArrayAdapter $storage = null; + public function createCachePool(): TaggableCacheItemPoolInterface { - return new TaggableCachePool(); + return new TaggableCachePool($this->sharedStorage(), 'first'); + } + + public function createCachePoolWithOtherNamespace(): TaggableCacheItemPoolInterface + { + return new TaggableCachePool($this->sharedStorage(), 'second'); + } + + private function sharedStorage(): ArrayAdapter + { + return $this->storage ??= new ArrayAdapter(); } public function testDataProviderExposesCases()