diff --git a/Model/Entries.php b/Model/Entries.php index 7e37bb5..cbd320b 100644 --- a/Model/Entries.php +++ b/Model/Entries.php @@ -5,17 +5,14 @@ namespace SamJUK\CacheDebounce\Model; use SamJUK\CacheDebounce\Model\Config; +use SamJUK\CacheDebounce\Model\Storage\QueueStorageInterface; use Magento\CacheInvalidate\Model\PurgeCache; -use Magento\Framework\App\ResourceConnection; use Psr\Log\LoggerInterface; class Entries { - /** @var string $tableName */ - private $tableName; - - /** @var ResourceConnection $resourceConnection */ - private $resourceConnection; + /** @var QueueStorageInterface $storage */ + private $storage; /** @var PurgeCache $purgeCache */ private $purgeCache; @@ -29,14 +26,13 @@ class Entries public function __construct( Config $config, PurgeCache $purgeCache, - ResourceConnection $resourceConnection, + QueueStorageInterface $storage, LoggerInterface $logger ) { $this->config = $config; $this->purgeCache = $purgeCache; - $this->resourceConnection = $resourceConnection; + $this->storage = $storage; $this->logger = $logger; - $this->tableName = $resourceConnection->getTableName('samjuk_cache_debounce'); } /** @@ -44,22 +40,7 @@ public function __construct( */ public function add(array $tags) : void { - if (!$tags) { - return; - } - - $this->resourceConnection->getConnection() - ->insertArray($this->tableName, ['tag'], $tags); - } - - /** - * Get all tags from the purge queue - */ - public function get() : array - { - $connection = $this->resourceConnection->getConnection(); - $query = $connection->select()->from($this->tableName, 'tag')->distinct(); - return $connection->fetchCol($query); + $this->storage->add($tags); } /** @@ -67,12 +48,16 @@ public function get() : array */ public function flush() : void { - $tags = $this->get(); - if (count($tags) === 0) { + $batchId = $this->storage->activeBatch(); + if ($batchId === '') { + $batchId = $this->storage->claim(); + } + if ($batchId === '') { $this->logger->debug("[CacheDebounce] Nothing to flush"); return; } + $tags = $this->storage->tags($batchId); $this->logger->debug("[CacheDebounce] Flushing Tags: " . json_encode($tags)); $this->config->setShouldDebouncePurgeRequest(false); @@ -81,12 +66,20 @@ public function flush() : void if (!$success) { $this->logger->error( - "[CacheDebounce] Purge request failed — leaving tags queued for retry: " . json_encode($tags) + "[CacheDebounce] Purge request failed — releasing batch $batchId for retry: " + . json_encode($tags) ); + $this->storage->release($batchId); return; } - $this->resourceConnection->getConnection()->delete($this->tableName); + $this->storage->clear($batchId); + } catch (\Throwable $e) { + $this->logger->error( + "[CacheDebounce] Purge request threw — releasing batch $batchId for retry: " . $e->getMessage() + ); + $this->storage->release($batchId); + throw $e; } finally { $this->config->setShouldDebouncePurgeRequest(true); } diff --git a/Model/Storage/Database.php b/Model/Storage/Database.php new file mode 100644 index 0000000..dd16af5 --- /dev/null +++ b/Model/Storage/Database.php @@ -0,0 +1,126 @@ +resourceConnection = $resourceConnection; + $this->tableName = $resourceConnection->getTableName('samjuk_cache_debounce'); + } + + /** + * @inheritDoc + * + * Dedupes against pending tags at the app level; no PK for this yet. + */ + public function add(array $tags): void + { + $tags = array_values(array_unique(array_filter($tags, fn ($tag) => $tag !== null && $tag !== ''))); + if (!$tags) { + return; + } + + $newTags = array_diff($tags, $this->tags(self::UNCLAIMED_BATCH_ID)); + if (!$newTags) { + return; + } + + $rows = []; + foreach ($newTags as $tag) { + $rows[] = [self::UNCLAIMED_BATCH_ID, $tag]; + } + + $this->resourceConnection->getConnection()->insertArray( + $this->tableName, + ['batch_id', 'tag'], + $rows, + AdapterInterface::INSERT_IGNORE + ); + } + + /** + * @inheritDoc + */ + public function claim(): string + { + $batchId = bin2hex(random_bytes(16)); + + $affectedRows = $this->resourceConnection->getConnection()->update( + $this->tableName, + ['batch_id' => $batchId], + $this->quoteBatchId(self::UNCLAIMED_BATCH_ID) + ); + + return $affectedRows > 0 ? $batchId : ''; + } + + /** + * @inheritDoc + */ + public function tags(string $batchId): array + { + $connection = $this->resourceConnection->getConnection(); + $query = $connection->select() + ->from($this->tableName, 'tag') + ->where('batch_id = ?', $batchId); + + return $connection->fetchCol($query); + } + + /** + * @inheritDoc + */ + public function clear(string $batchId): void + { + $this->resourceConnection->getConnection()->delete( + $this->tableName, + $this->quoteBatchId($batchId) + ); + } + + /** + * @inheritDoc + */ + public function release(string $batchId): void + { + $this->add($this->tags($batchId)); + $this->clear($batchId); + } + + /** + * @inheritDoc + */ + public function activeBatch(): string + { + $connection = $this->resourceConnection->getConnection(); + $query = $connection->select() + ->from($this->tableName, ['batch_id']) + ->where('batch_id != ?', self::UNCLAIMED_BATCH_ID) + ->limit(1); + + $batchId = $connection->fetchOne($query); + + return $batchId !== false ? (string)$batchId : ''; + } + + private function quoteBatchId(string $batchId): string + { + return $this->resourceConnection->getConnection()->quoteInto('batch_id = ?', $batchId); + } +} diff --git a/Model/Storage/QueueStorageInterface.php b/Model/Storage/QueueStorageInterface.php new file mode 100644 index 0000000..723049a --- /dev/null +++ b/Model/Storage/QueueStorageInterface.php @@ -0,0 +1,39 @@ +moduleDataSetup = $moduleDataSetup; + } + + public function apply(): void + { + // DELETE not TRUNCATE: data patches run inside a transaction. + $connection = $this->moduleDataSetup->getConnection(); + $connection->delete($this->moduleDataSetup->getTable('samjuk_cache_debounce')); + } + + public static function getDependencies(): array + { + return []; + } + + public function getAliases(): array + { + return []; + } +} diff --git a/Test/Integration/Model/EntriesTest.php b/Test/Integration/Model/EntriesTest.php index f6b54d2..c607a40 100644 --- a/Test/Integration/Model/EntriesTest.php +++ b/Test/Integration/Model/EntriesTest.php @@ -3,7 +3,10 @@ namespace SamJUK\CacheDebounce\Test\Integration\Model; use PHPUnit\Framework\TestCase; +use Magento\Framework\App\ResourceConnection; use Magento\TestFramework\ObjectManager; +use SamJUK\CacheDebounce\Model\Entries; +use SamJUK\CacheDebounce\Model\Storage\QueueStorageInterface; class EntriesTest extends TestCase { @@ -11,19 +14,74 @@ class EntriesTest extends TestCase protected $objectManager; private $cacheDebouncedEntries; + private $storage; + private $resourceConnection; protected function setUp(): void { $this->objectManager = ObjectManager::getInstance(); - $this->cacheDebouncedEntries = $this->objectManager->get(\SamJUK\CacheDebounce\Model\Entries::class); + $this->cacheDebouncedEntries = $this->objectManager->get(Entries::class); + $this->storage = $this->objectManager->get(QueueStorageInterface::class); + + // Independent of the classes under test, so a prior test failing + // mid-assertion can never leak rows into the next test. + $this->resourceConnection = $this->objectManager->get(ResourceConnection::class); + $this->truncateTable(); + } + + protected function tearDown(): void + { + $this->truncateTable(); + } + + private function truncateTable(): void + { + $connection = $this->resourceConnection->getConnection(); + $connection->delete($this->resourceConnection->getTableName('samjuk_cache_debounce')); } public function testDebouncedEntriesStorage() { $this->cacheDebouncedEntries->add(self::CACHE_TAGS); - $this->assertEquals(self::CACHE_TAGS, $this->cacheDebouncedEntries->get()); - $this->cacheDebouncedEntries->flush(); - $this->assertEquals([], $this->cacheDebouncedEntries->get()); + $batchId = $this->storage->claim(); + $this->assertEquals(self::CACHE_TAGS, $this->storage->tags($batchId)); + + $this->storage->clear($batchId); + $this->assertEquals([], $this->storage->tags($batchId)); + } + + /** + * Regression test for the add-during-purge race: a tag queued after a + * batch has been claimed must survive into the *next* batch, not be + * silently dropped by the trailing clear() of the batch currently + * being purged. + */ + public function testTagAddedWhileBatchIsClaimedSurvivesIntoNextBatch() + { + $this->cacheDebouncedEntries->add(['cat_c_1']); + + $batchId = $this->storage->claim(); + + $this->cacheDebouncedEntries->add(['cat_c_2']); + + $this->assertEquals(['cat_c_1'], $this->storage->tags($batchId)); + + $this->storage->clear($batchId); + + $nextBatchId = $this->storage->claim(); + $this->assertEquals(['cat_c_2'], $this->storage->tags($nextBatchId)); + } + + public function testSecondConcurrentClaimReturnsEmptyString() + { + $this->cacheDebouncedEntries->add(self::CACHE_TAGS); + + $batchId = $this->storage->claim(); + $this->assertNotSame('', $batchId); + + $this->assertSame('', $this->storage->claim()); + + $this->storage->clear($batchId); } } diff --git a/Test/Integration/Model/Storage/DatabaseTest.php b/Test/Integration/Model/Storage/DatabaseTest.php new file mode 100644 index 0000000..f99004b --- /dev/null +++ b/Test/Integration/Model/Storage/DatabaseTest.php @@ -0,0 +1,141 @@ +objectManager = ObjectManager::getInstance(); + $this->storage = $this->objectManager->create(Database::class); + + // Independent of the class under test, so a prior test failing + // mid-assertion can never leak rows into the next test. + $this->resourceConnection = $this->objectManager->get(ResourceConnection::class); + $this->truncateTable(); + } + + protected function tearDown(): void + { + $this->truncateTable(); + } + + private function truncateTable(): void + { + $connection = $this->resourceConnection->getConnection(); + $connection->delete($this->resourceConnection->getTableName('samjuk_cache_debounce')); + } + + public function testAddClaimTagsClearRoundTrip() + { + $this->storage->add(['cat_c_1', 'cat_c_2']); + + $batchId = $this->storage->claim(); + $this->assertNotSame('', $batchId); + + $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($batchId)); + + $this->storage->clear($batchId); + $this->assertEquals([], $this->storage->tags($batchId)); + } + + public function testAddDuringClaimIsNotLostOrDuplicated() + { + $this->storage->add(['cat_c_1']); + + $batchId = $this->storage->claim(); + + // Overlapping tag added after the claim snapshot — must land in the + // next batch, not be silently merged into the one already claimed. + $this->storage->add(['cat_c_1', 'cat_c_2']); + + $this->assertEquals(['cat_c_1'], $this->storage->tags($batchId)); + + $this->storage->clear($batchId); + + $nextBatchId = $this->storage->claim(); + $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($nextBatchId)); + } + + public function testAddDoesNotDuplicateAlreadyPendingTags() + { + $this->storage->add(['cat_c_1']); + $this->storage->add(['cat_c_1', 'cat_c_2']); + + $batchId = $this->storage->claim(); + + $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($batchId)); + } + + public function testAddIgnoresNullAndEmptyTags() + { + $this->storage->add(['cat_c_1', null, '']); + + $batchId = $this->storage->claim(); + + $this->assertEquals(['cat_c_1'], $this->storage->tags($batchId)); + } + + public function testSecondConcurrentClaimReturnsEmptyString() + { + $this->storage->add(['cat_c_1']); + + $firstBatchId = $this->storage->claim(); + $this->assertNotSame('', $firstBatchId); + + $this->assertSame('', $this->storage->claim()); + + $this->storage->clear($firstBatchId); + } + + public function testReleaseMakesTheBatchClaimableAgain() + { + $this->storage->add(['cat_c_1', 'cat_c_2']); + $batchId = $this->storage->claim(); + + $this->storage->release($batchId); + + $this->assertEquals([], $this->storage->tags($batchId)); + + $nextBatchId = $this->storage->claim(); + $this->assertEquals(['cat_c_1', 'cat_c_2'], $this->storage->tags($nextBatchId)); + } + + public function testReleaseMergesCleanlyWithTagsReQueuedWhileClaimed() + { + $this->storage->add(['cat_c_1']); + $batchId = $this->storage->claim(); + + // Same tag re-queued while the original batch is still in flight — + // release() must not end up with two pending rows for it. + $this->storage->add(['cat_c_1']); + + $this->storage->release($batchId); + + $nextBatchId = $this->storage->claim(); + $this->assertEquals(['cat_c_1'], $this->storage->tags($nextBatchId)); + } + + public function testActiveBatchTracksClaimedWorkUntilItIsCleared() + { + $this->assertSame('', $this->storage->activeBatch()); + + $this->storage->add(['cat_c_1']); + $batchId = $this->storage->claim(); + + $this->assertSame($batchId, $this->storage->activeBatch()); + + $this->storage->clear($batchId); + + $this->assertSame('', $this->storage->activeBatch()); + } +} diff --git a/Test/Unit/Model/EntriesTest.php b/Test/Unit/Model/EntriesTest.php index 85a40f7..e7a7167 100644 --- a/Test/Unit/Model/EntriesTest.php +++ b/Test/Unit/Model/EntriesTest.php @@ -2,69 +2,91 @@ namespace SamJUK\CacheDebounce\Test\Unit\Model; +use Magento\CacheInvalidate\Model\PurgeCache; +use Magento\Framework\App\Config\ScopeConfigInterface; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use SamJUK\CacheDebounce\Model\Config as CacheDebounceConfig; use SamJUK\CacheDebounce\Model\Entries as CacheDebounceEntries; +use SamJUK\CacheDebounce\Model\Storage\QueueStorageInterface; class EntriesTest extends TestCase { + private const BATCH_ID = 'batch-123'; private const CACHE_TAGS = ['cat_c_1', 'cat_c_2', 'cat_c_p_1']; private $cacheDebounceConfig; private $purgeCacheModel; - private $connection; - private $resourceConnection; + private $storage; private $loggerInterface; private $cacheDebounceEntries; - private $select; protected function setUp(): void { - $this->select = $this->createMock(\Magento\Framework\DB\Select::class); - $this->select->method('from')->willReturn($this->select); - $this->select->method('distinct')->willReturn($this->select); - $this->connection = $this->createMock(\Magento\Framework\DB\Adapter\AdapterInterface::class); - $this->connection->method('select')->willReturn($this->select); - $this->cacheDebounceConfig = $this->createMock(\SamJUK\CacheDebounce\Model\Config::class); - $this->resourceConnection = $this->createMock(\Magento\Framework\App\ResourceConnection::class); - $this->resourceConnection->method('getConnection')->willReturn($this->connection); - $this->purgeCacheModel = $this->createMock(\Magento\CacheInvalidate\Model\PurgeCache::class); - $this->loggerInterface = $this->createMock(\Psr\Log\LoggerInterface::class); + $this->cacheDebounceConfig = $this->createMock(CacheDebounceConfig::class); + $this->storage = $this->createMock(QueueStorageInterface::class); + $this->purgeCacheModel = $this->createMock(PurgeCache::class); + $this->loggerInterface = $this->createMock(LoggerInterface::class); $this->cacheDebounceEntries = new CacheDebounceEntries( $this->cacheDebounceConfig, $this->purgeCacheModel, - $this->resourceConnection, + $this->storage, $this->loggerInterface ); } - public function testFlushTags() + public function testFlushDoesNothingWhenNothingIsClaimed() { - $this->connection->method('fetchCol')->willReturn([ - self::CACHE_TAGS - ]); + $this->storage->method('activeBatch')->willReturn(''); + $this->storage->method('claim')->willReturn(''); + + $this->purgeCacheModel->expects($this->never())->method('sendPurgeRequest'); + + $this->cacheDebounceEntries->flush(); + } + + public function testFlushPurgesClaimedTagsAndClearsBatch() + { + $this->storage->method('activeBatch')->willReturn(''); + $this->storage->method('claim')->willReturn(self::BATCH_ID); + $this->storage->method('tags')->with(self::BATCH_ID)->willReturn(self::CACHE_TAGS); $this->purgeCacheModel->expects($this->once()) ->method('sendPurgeRequest') - ->with([self::CACHE_TAGS]) + ->with(self::CACHE_TAGS) ->willReturn(true); - $this->connection->expects($this->once())->method('delete'); + $this->storage->expects($this->once())->method('clear')->with(self::BATCH_ID); + + $this->cacheDebounceEntries->flush(); + } + + public function testFlushResumesAnAlreadyClaimedBatchInsteadOfClaimingNew() + { + $this->storage->method('activeBatch')->willReturn(self::BATCH_ID); + $this->storage->method('tags')->with(self::BATCH_ID)->willReturn(self::CACHE_TAGS); + $this->storage->expects($this->never())->method('claim'); + + $this->purgeCacheModel->method('sendPurgeRequest')->willReturn(true); + + $this->storage->expects($this->once())->method('clear')->with(self::BATCH_ID); $this->cacheDebounceEntries->flush(); } - public function testFlushDoesNotClearQueueWhenPurgeRequestFails() + public function testFlushReleasesBatchInsteadOfClearingWhenPurgeRequestFails() { - $this->connection->method('fetchCol')->willReturn([ - self::CACHE_TAGS - ]); + $this->storage->method('activeBatch')->willReturn(''); + $this->storage->method('claim')->willReturn(self::BATCH_ID); + $this->storage->method('tags')->willReturn(self::CACHE_TAGS); $this->purgeCacheModel->expects($this->once()) ->method('sendPurgeRequest') - ->with([self::CACHE_TAGS]) + ->with(self::CACHE_TAGS) ->willReturn(false); - $this->connection->expects($this->never())->method('delete'); + $this->storage->expects($this->never())->method('clear'); + $this->storage->expects($this->once())->method('release')->with(self::BATCH_ID); $this->loggerInterface->expects($this->once())->method('error'); @@ -73,20 +95,25 @@ public function testFlushDoesNotClearQueueWhenPurgeRequestFails() public function testFlushResetsDebounceFlagEvenWhenPurgeRequestThrows() { - $scopeConfig = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $scopeConfig = $this->createMock(ScopeConfigInterface::class); $scopeConfig->method('isSetFlag')->willReturn(true); - $config = new \SamJUK\CacheDebounce\Model\Config($scopeConfig); + $config = new CacheDebounceConfig($scopeConfig); - $this->connection->method('fetchCol')->willReturn(self::CACHE_TAGS); + $this->storage->method('activeBatch')->willReturn(''); + $this->storage->method('claim')->willReturn(self::BATCH_ID); + $this->storage->method('tags')->willReturn(self::CACHE_TAGS); $this->purgeCacheModel->method('sendPurgeRequest')->willThrowException(new \RuntimeException('varnish down')); $entries = new CacheDebounceEntries( $config, $this->purgeCacheModel, - $this->resourceConnection, + $this->storage, $this->loggerInterface ); + $this->storage->expects($this->never())->method('clear'); + $this->storage->expects($this->once())->method('release')->with(self::BATCH_ID); + $this->assertTrue($config->shouldDebouncePurgeRequest()); try { @@ -99,19 +126,17 @@ public function testFlushResetsDebounceFlagEvenWhenPurgeRequestThrows() $this->assertTrue($config->shouldDebouncePurgeRequest()); } - public function testAddWithEmptyTagsDoesNotTouchConnection() + public function testAddDelegatesToStorage() { - $this->connection->expects($this->never())->method('insertArray'); + $this->storage->expects($this->once())->method('add')->with(self::CACHE_TAGS); - $this->cacheDebounceEntries->add([]); + $this->cacheDebounceEntries->add(self::CACHE_TAGS); } - public function testAddWithTagsInsertsThem() + public function testAddWithEmptyTagsStillDelegatesToStorage() { - $this->connection->expects($this->once()) - ->method('insertArray') - ->with($this->anything(), ['tag'], self::CACHE_TAGS); + $this->storage->expects($this->once())->method('add')->with([]); - $this->cacheDebounceEntries->add(self::CACHE_TAGS); + $this->cacheDebounceEntries->add([]); } } diff --git a/Test/Unit/Model/Storage/DatabaseTest.php b/Test/Unit/Model/Storage/DatabaseTest.php new file mode 100644 index 0000000..e25fb59 --- /dev/null +++ b/Test/Unit/Model/Storage/DatabaseTest.php @@ -0,0 +1,189 @@ +connection = $this->createMock(AdapterInterface::class); + $this->connection->method('quoteInto')->willReturnCallback( + fn ($text, $value) => str_replace('?', "'$value'", $text) + ); + $this->resourceConnection = $this->createMock(ResourceConnection::class); + $this->resourceConnection->method('getConnection')->willReturn($this->connection); + $this->resourceConnection->method('getTableName')->willReturn(self::TABLE_NAME); + + $this->storage = new Database($this->resourceConnection); + } + + private function stubSelect(): void + { + $select = $this->createMock(\Magento\Framework\DB\Select::class); + $select->method('from')->willReturn($select); + $select->method('where')->willReturn($select); + $this->connection->method('select')->willReturn($select); + } + + public function testAddWithEmptyTagsDoesNotTouchConnection() + { + $this->connection->expects($this->never())->method('insertArray'); + + $this->storage->add([]); + } + + public function testAddInsertsTagsAsUnclaimedWithInsertIgnore() + { + $this->stubSelect(); + $this->connection->method('fetchCol')->willReturn([]); + + $this->connection->expects($this->once()) + ->method('insertArray') + ->with( + self::TABLE_NAME, + ['batch_id', 'tag'], + [['', 'cat_c_1'], ['', 'cat_c_2']], + AdapterInterface::INSERT_IGNORE + ); + + $this->storage->add(['cat_c_1', 'cat_c_2']); + } + + public function testAddFiltersOutNullAndEmptyTags() + { + $this->stubSelect(); + $this->connection->method('fetchCol')->willReturn([]); + + $this->connection->expects($this->once()) + ->method('insertArray') + ->with( + self::TABLE_NAME, + ['batch_id', 'tag'], + [['', 'cat_c_1']], + AdapterInterface::INSERT_IGNORE + ); + + $this->storage->add(['cat_c_1', null, '']); + } + + public function testAddSkipsTagsAlreadyPending() + { + $this->stubSelect(); + $this->connection->method('fetchCol')->willReturn(['cat_c_1']); + + $this->connection->expects($this->once()) + ->method('insertArray') + ->with( + self::TABLE_NAME, + ['batch_id', 'tag'], + [['', 'cat_c_2']], + AdapterInterface::INSERT_IGNORE + ); + + $this->storage->add(['cat_c_1', 'cat_c_2']); + } + + public function testAddInsertsNothingWhenEveryTagIsAlreadyPending() + { + $this->stubSelect(); + $this->connection->method('fetchCol')->willReturn(['cat_c_1']); + + $this->connection->expects($this->never())->method('insertArray'); + + $this->storage->add(['cat_c_1']); + } + + public function testClaimReturnsEmptyStringWhenNothingPending() + { + $this->connection->method('update')->willReturn(0); + + $this->assertSame('', $this->storage->claim()); + } + + public function testClaimReturnsBatchIdWhenRowsClaimed() + { + $this->connection->method('update')->willReturn(2); + + $batchId = $this->storage->claim(); + + $this->assertNotSame('', $batchId); + $this->assertSame(32, strlen($batchId)); + } + + public function testTagsSelectsScopedToBatchId() + { + $select = $this->createMock(\Magento\Framework\DB\Select::class); + $select->method('from')->willReturn($select); + $select->method('where')->with('batch_id = ?', 'batch-123')->willReturn($select); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchCol')->willReturn(['cat_c_1']); + + $this->assertSame(['cat_c_1'], $this->storage->tags('batch-123')); + } + + public function testClearDeletesOnlyThatBatch() + { + $this->connection->expects($this->once()) + ->method('delete') + ->with(self::TABLE_NAME, "batch_id = 'batch-123'"); + + $this->storage->clear('batch-123'); + } + + public function testReleaseReQueuesBatchTagsAsUnclaimedThenClearsTheBatch() + { + $this->stubSelect(); + // First call is tags($batchId) reading the claimed batch; second is + // add()'s own dedup check against currently-pending tags. + $this->connection->method('fetchCol')->willReturnOnConsecutiveCalls(['cat_c_1', 'cat_c_2'], []); + + $this->connection->expects($this->once()) + ->method('insertArray') + ->with( + self::TABLE_NAME, + ['batch_id', 'tag'], + [['', 'cat_c_1'], ['', 'cat_c_2']], + AdapterInterface::INSERT_IGNORE + ); + $this->connection->expects($this->once()) + ->method('delete') + ->with(self::TABLE_NAME, "batch_id = 'batch-123'"); + + $this->storage->release('batch-123'); + } + + public function testActiveBatchReturnsEmptyStringWhenNothingClaimed() + { + $select = $this->createMock(\Magento\Framework\DB\Select::class); + $select->method('from')->willReturn($select); + $select->method('where')->with('batch_id != ?', '')->willReturn($select); + $select->method('limit')->with(1)->willReturn($select); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchOne')->willReturn(false); + + $this->assertSame('', $this->storage->activeBatch()); + } + + public function testActiveBatchReturnsIdOfAlreadyClaimedBatch() + { + $select = $this->createMock(\Magento\Framework\DB\Select::class); + $select->method('from')->willReturn($select); + $select->method('where')->willReturn($select); + $select->method('limit')->willReturn($select); + $this->connection->method('select')->willReturn($select); + $this->connection->method('fetchOne')->willReturn('batch-123'); + + $this->assertSame('batch-123', $this->storage->activeBatch()); + } +} diff --git a/etc/db_schema.xml b/etc/db_schema.xml index 41f0d15..80c6838 100644 --- a/etc/db_schema.xml +++ b/etc/db_schema.xml @@ -1,6 +1,9 @@ + + +
diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json new file mode 100644 index 0000000..fcb747e --- /dev/null +++ b/etc/db_schema_whitelist.json @@ -0,0 +1,9 @@ +{ + "samjuk_cache_debounce": { + "column": { + "batch_id": true, + "tag": true, + "created_at": true + } + } +} diff --git a/etc/di.xml b/etc/di.xml index 36fd265..68f549a 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -1,5 +1,7 @@ + +