diff --git a/.gitignore b/.gitignore index fca9e04..6cffc53 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -templates/frontOffice/default/header_highlights/assets/node_modules \ No newline at end of file +templates/frontOffice/default/header_highlights/assets/node_modules +/.phpunit.cache/ diff --git a/Config/module.xml b/Config/module.xml index d1c63fb..fc1e106 100644 --- a/Config/module.xml +++ b/Config/module.xml @@ -13,7 +13,7 @@ en_US fr_FR - 2.1.4 + 2.2.0 Loïc MO diff --git a/HeaderHighlights.php b/HeaderHighlights.php index cffec0a..7e2ebf7 100644 --- a/HeaderHighlights.php +++ b/HeaderHighlights.php @@ -4,12 +4,15 @@ use HeaderHighlights\Model\HeaderHighlights as HeaderHighlightsModel; use HeaderHighlights\Model\HeaderHighlightsImage; +use HeaderHighlights\SharedCache\HeaderHighlightsPurgeListener; +use HeaderHighlights\SharedCache\HeaderHighlightsSharedCache; use Propel\Runtime\Connection\ConnectionInterface; use Propel\Runtime\Propel; use Symfony\Component\DependencyInjection\Loader\Configurator\ServicesConfigurator; use Symfony\Component\Finder\Finder; use Thelia\Core\Template\TemplateDefinition; use Thelia\Core\Install\Database; +use Thelia\Core\SharedCache\SharedCacheContributorInterface; use Thelia\Model\Base\CategoryQuery; use Thelia\Model\ConfigQuery; use Thelia\Model\LangQuery; @@ -30,9 +33,21 @@ class HeaderHighlights extends BaseModule public static function configureServices(ServicesConfigurator $servicesConfigurator): void { $servicesConfigurator->load(self::getModuleCode().'\\', __DIR__) - ->exclude([__DIR__.'/I18n/*', __DIR__.'/Config/**/*.php', __DIR__.'/Tests/*', __DIR__.'/HeaderHighlights.php']) + ->exclude([__DIR__.'/I18n/*', __DIR__.'/Config/**/*.php', __DIR__.'/Tests/*', __DIR__.'/SharedCache/*', __DIR__.'/HeaderHighlights.php']) ->autowire(true) ->autoconfigure(true); + + // The shared cache of the public pages is a soft dependency: on a core that + // predates it the interface does not exist, and reflecting a class that + // implements it would be a fatal error, not a skip. + if (interface_exists(SharedCacheContributorInterface::class)) { + $servicesConfigurator->set(HeaderHighlightsSharedCache::class) + ->autoconfigure(true); + + $servicesConfigurator->set(HeaderHighlightsPurgeListener::class) + ->autowire(true) + ->autoconfigure(true); + } } /** diff --git a/Readme.md b/Readme.md index 40ddcd6..6ac9e4a 100644 --- a/Readme.md +++ b/Readme.md @@ -56,6 +56,16 @@ unstyled band. The call to action is the theme's `Molecules:Button:Base` component, `size` `large` on the lead block and `small` on the short ones. +## Shared cache of the public pages + +On a Thelia core that brings the shared cache of the public pages +(`shared_cache_mode`), the module declares `/api/header-highlights` as a +resource every visitor reads the same: a page that shows the highlights can +still be kept by the proxy, tagged with `header_highlights`, and saving or +deleting a highlight or its image purges that key. Uploading a new picture +purges every page, like any image of a module. On an older core the module +behaves as before. + ## Hook You must define 3 hooks to render (one for html, one for css and one for js) diff --git a/SharedCache/HeaderHighlightsPurgeListener.php b/SharedCache/HeaderHighlightsPurgeListener.php new file mode 100644 index 0000000..737f177 --- /dev/null +++ b/SharedCache/HeaderHighlightsPurgeListener.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace HeaderHighlights\SharedCache; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Thelia\Core\SharedCache\SharedCacheInvalidator; + +/** + * Purges the pages that show the highlights when one of their rows is written or deleted. + * + * The configuration screen saves the models directly, so the Propel events of the four + * tables are the only thing every write goes through. They are named by their string: the + * classes Propel generates for them are not loaded before the kernel boots. + */ +final readonly class HeaderHighlightsPurgeListener implements EventSubscriberInterface +{ + private const TABLES = ['header_highlights', 'header_highlights_i18n', 'header_highlights_image', 'header_highlights_image_i18n']; + + public function __construct( + private SharedCacheInvalidator $invalidator, + ) { + } + + public static function getSubscribedEvents(): array + { + $events = []; + + foreach (self::TABLES as $table) { + $events['propel.post.save.'.$table] = 'purge'; + $events['propel.post.delete.'.$table] = 'purge'; + } + + return $events; + } + + public function purge(): void + { + $this->invalidator->purge([HeaderHighlightsSharedCache::KEY]); + } +} diff --git a/SharedCache/HeaderHighlightsSharedCache.php b/SharedCache/HeaderHighlightsSharedCache.php new file mode 100644 index 0000000..d652cf8 --- /dev/null +++ b/SharedCache/HeaderHighlightsSharedCache.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace HeaderHighlights\SharedCache; + +use Thelia\Core\SharedCache\SharedCacheContributorInterface; + +/** + * The highlights are the same for every visitor: a page that reads them through + * `/api/header-highlights` may still be kept by the shared cache of the public pages, + * tagged with the key a change of the highlights purges. + */ +final readonly class HeaderHighlightsSharedCache implements SharedCacheContributorInterface +{ + public const KEY = 'header_highlights'; + + public const RESOURCE = '/api/header-highlights'; + + public function readableResources(): array + { + return [self::RESOURCE => [self::KEY]]; + } + + public function shareableViews(): array + { + return []; + } +} diff --git a/Tests/Integration/SharedCache/HeaderHighlightsSharedCacheTest.php b/Tests/Integration/SharedCache/HeaderHighlightsSharedCacheTest.php new file mode 100644 index 0000000..9c12f41 --- /dev/null +++ b/Tests/Integration/SharedCache/HeaderHighlightsSharedCacheTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace HeaderHighlights\Tests\Integration\SharedCache; + +use HeaderHighlights\Model\HeaderHighlights as HeaderHighlightsModel; +use HeaderHighlights\Model\HeaderHighlightsImage; +use HeaderHighlights\SharedCache\HeaderHighlightsSharedCache; +use Thelia\Core\SharedCache\SharedCacheContributorInterface; +use Thelia\Core\SharedCache\SharedCacheInvalidationListener; +use Thelia\Core\SharedCache\SharedCacheKeyCollector; +use Thelia\Core\SharedCache\SharedCachePurgerInterface; +use Thelia\Test\IntegrationTestCase; + +/** + * The highlights do not keep a page out of the shared cache of the public pages, and a + * change of the highlights purges the pages that show them. + */ +final class HeaderHighlightsSharedCacheTest extends IntegrationTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + if (!interface_exists(SharedCacheContributorInterface::class)) { + self::markTestSkipped('The core of this shop predates the shared cache of the public pages.'); + } + } + + public function testAPageThatReadsTheHighlightsCanStillBeSharedAndIsTaggedWithThem(): void + { + $collector = static::getContainer()->get(SharedCacheKeyCollector::class); + $collector->reset(); + + $collector->record(HeaderHighlightsSharedCache::RESOURCE); + + self::assertTrue($collector->isComplete()); + self::assertSame([HeaderHighlightsSharedCache::KEY], $collector->keys()); + } + + public function testSavingAHighlightPurgesThePagesThatShowIt(): void + { + $purger = $this->recordingPurger(); + + $highlight = (new HeaderHighlightsModel())->setDisplayType('desktop')->setImageBlock('1'); + $highlight->setLocale('en_US')->setTitle('Autumn sale'); + $highlight->save($this->getPropelConnection()); + static::getContainer()->get(SharedCacheInvalidationListener::class)->flush(); + + self::assertSame([[HeaderHighlightsSharedCache::KEY]], $purger->calls); + } + + public function testDeletingTheImageOfAHighlightPurgesThePagesThatShowIt(): void + { + $purger = $this->recordingPurger(); + $highlight = (new HeaderHighlightsModel())->setDisplayType('mobile')->setImageBlock('2'); + $highlight->save($this->getPropelConnection()); + $image = (new HeaderHighlightsImage())->setHeaderHighlightsId((int) $highlight->getId())->setFile('probe.webp'); + $image->save($this->getPropelConnection()); + static::getContainer()->get(SharedCacheInvalidationListener::class)->flush(); + $purger->calls = []; + + $image->delete($this->getPropelConnection()); + static::getContainer()->get(SharedCacheInvalidationListener::class)->flush(); + + self::assertSame([[HeaderHighlightsSharedCache::KEY]], $purger->calls); + } + + /** + * @return SharedCachePurgerInterface&object{calls: list>} + */ + private function recordingPurger(): SharedCachePurgerInterface + { + $purger = new class implements SharedCachePurgerInterface { + /** @var list> */ + public array $calls = []; + + public function purge(array $keys): void + { + $this->calls[] = $keys; + } + }; + + static::getContainer()->set(SharedCachePurgerInterface::class, $purger); + + return $purger; + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..8592ed3 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,18 @@ +# Shared cache symbols of a core newer than the published one, used only when interface_exists() finds them +# (HeaderHighlights::configureServices). Kept out of the report so that PHPStan runs against the published core; +# an entry the core catches up with is not reported. +parameters: + # PHPStan cannot ignore a class that implements an unknown interface: the class is left out of the analysis instead (still scanned, so its name resolves). + excludePaths: + analyse: + - SharedCache/HeaderHighlightsSharedCache.php + reportUnmatchedIgnoredErrors: false + ignoreErrors: + - + message: '#^Parameter \$invalidator of method HeaderHighlights\\SharedCache\\HeaderHighlightsPurgeListener::__construct\(\) has invalid type Thelia\\Core\\SharedCache\\SharedCacheInvalidator\.$#' + count: 1 + path: SharedCache/HeaderHighlightsPurgeListener.php + - + message: '#^Property HeaderHighlights\\SharedCache\\HeaderHighlightsPurgeListener::\$invalidator has unknown class Thelia\\Core\\SharedCache\\SharedCacheInvalidator as its type\.$#' + count: 1 + path: SharedCache/HeaderHighlightsPurgeListener.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..1ed8f2d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + Tests/Integration + + +