diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f296bfa..b6cfef1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -16,7 +16,7 @@ parameters: path: src/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepository.php - - message: "#^Method App\\\\Feed\\\\Infrastructure\\\\Persistence\\\\Doctrine\\\\Article\\\\DoctrineArticleRepository\\:\\:findLatest\\(\\) should return array\\ but returns mixed\\.$#" + message: "#^Parameter \\#1 \\$id of class App\\\\Feed\\\\Domain\\\\Article\\\\ArticleId constructor expects string, mixed given\\.$#" count: 1 path: src/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepository.php diff --git a/src-dev/Feed/Repository/InMemoryArticleRepository.php b/src-dev/Feed/Repository/InMemoryArticleRepository.php index 34607ef..ced35ca 100644 --- a/src-dev/Feed/Repository/InMemoryArticleRepository.php +++ b/src-dev/Feed/Repository/InMemoryArticleRepository.php @@ -38,13 +38,16 @@ public function count(): int return count($this->entities); } - public function findLatest(int $offset, int $numberOfArticles): array + public function findLatestIds(int $offset, int $numberOfArticles): array { $entities = $this->entities; usort($entities, fn (Article $a, Article $b) => $a->getUpdated() > $b->getUpdated() ? -1 : 1); - return array_values(array_slice($entities, $offset, $numberOfArticles)); + return array_map( + fn(Article $entity) => $entity->getId(), + array_values(array_slice($entities, $offset, $numberOfArticles)) + ); } public function findByUrl(string $url): ?Article diff --git a/src/Feed/Application/Listener/Article/InvalidateArticleCacheListener.php b/src/Feed/Application/Listener/Article/InvalidateArticleCacheListener.php new file mode 100644 index 0000000..5f33fdb --- /dev/null +++ b/src/Feed/Application/Listener/Article/InvalidateArticleCacheListener.php @@ -0,0 +1,22 @@ +cache->delete(sprintf(FeedCacheKeys::ARTICLE_WITH_ARTICLE_ID->value, $event->articleId)); + } +} diff --git a/src/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandler.php b/src/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandler.php index ec7af1a..532c3a0 100644 --- a/src/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandler.php +++ b/src/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandler.php @@ -6,11 +6,15 @@ use App\Feed\Application\Query\Article\LatestUpdatedArticlesQuery; use App\Feed\Domain\Article\Article; use App\Feed\Domain\Article\ArticleRepository; +use App\Feed\Infrastructure\Cache\FeedCacheKeys; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; final readonly class LatestUpdatedArticlesHandler { public function __construct( - private ArticleRepository $articleRepository + private ArticleRepository $articleRepository, + private CacheInterface $cache, ) { } @@ -19,9 +23,19 @@ public function __construct( */ public function __invoke(LatestUpdatedArticlesQuery $query): array { - return array_map( - ArticleReadModel::fromArticle(...), - $this->articleRepository->findLatest($query->offset, $query->numberOfArticles), - ); + $articleIds = $this->articleRepository->findLatestIds($query->offset, $query->numberOfArticles); + + $articles = []; + + foreach ($articleIds as $articleId) { + $articles[] = $this->cache->get( + sprintf(FeedCacheKeys::ARTICLE_WITH_ARTICLE_ID->value, $articleId), + fn() => ArticleReadModel::fromArticle( + $this->articleRepository->findOrThrow($articleId) + ), + ); + } + + return $articles; } } diff --git a/src/Feed/Domain/Article/ArticleRepository.php b/src/Feed/Domain/Article/ArticleRepository.php index 83f2130..6846359 100644 --- a/src/Feed/Domain/Article/ArticleRepository.php +++ b/src/Feed/Domain/Article/ArticleRepository.php @@ -20,9 +20,9 @@ public function count(): int; /** * @param int $numberOfArticles - * @return list
+ * @return list */ - public function findLatest( + public function findLatestIds( int $offset, int $numberOfArticles, ): array; diff --git a/src/Feed/Infrastructure/Cache/FeedCacheKeys.php b/src/Feed/Infrastructure/Cache/FeedCacheKeys.php index 9428dad..d034243 100644 --- a/src/Feed/Infrastructure/Cache/FeedCacheKeys.php +++ b/src/Feed/Infrastructure/Cache/FeedCacheKeys.php @@ -5,4 +5,5 @@ enum FeedCacheKeys : string { case TOTAL_ARTICLES_COUNT = 'total_articles_count'; + case ARTICLE_WITH_ARTICLE_ID = 'article_with_article_id_%s'; } diff --git a/src/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepository.php b/src/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepository.php index 37c4dbd..cb9c0df 100644 --- a/src/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepository.php +++ b/src/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepository.php @@ -50,19 +50,22 @@ public function count(): int /** * @param int $numberOfArticles - * @return List
+ * @return List */ - public function findLatest( + public function findLatestIds( int $offset, int $numberOfArticles, ): array { - return $this->createQueryBuilder('a') - ->orderBy('a.updated', Criteria::DESC) - ->setFirstResult($offset) - ->setMaxResults($numberOfArticles) - ->getQuery() - ->getResult() - ; + return array_map( + fn($id) => new ArticleId($id), + $this->createQueryBuilder('a') + ->select('a.id') + ->orderBy('a.updated', Criteria::DESC) + ->setFirstResult($offset) + ->setMaxResults($numberOfArticles) + ->getQuery() + ->getSingleColumnResult() + ); } public function findByUrl(string $url): ?Article diff --git a/tests/Functional/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepositoryTest.php b/tests/Functional/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepositoryTest.php index d95d8e6..1d2b8ea 100644 --- a/tests/Functional/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepositoryTest.php +++ b/tests/Functional/Feed/Infrastructure/Persistence/Doctrine/Article/DoctrineArticleRepositoryTest.php @@ -39,13 +39,13 @@ public function it_should_return_the_latest_articles_in_a_sorted_manner(): void $this->getDoctrine()->resetManager(); // Act - $articles = $this->repository->findLatest(0, 2); + $articles = $this->repository->findLatestIds(0, 2); // Assert self::assertCount(2, $articles); - self::assertEquals($article1->getId(), $articles[0]->getId()); - self::assertEquals($article3->getId(), $articles[1]->getId()); + self::assertEquals($article1->getId(), $articles[0]); + self::assertEquals($article3->getId(), $articles[1]); } /** diff --git a/tests/Unit/Feed/Application/Listener/Article/InvalidateArticleCacheListenerTest.php b/tests/Unit/Feed/Application/Listener/Article/InvalidateArticleCacheListenerTest.php new file mode 100644 index 0000000..559f5a8 --- /dev/null +++ b/tests/Unit/Feed/Application/Listener/Article/InvalidateArticleCacheListenerTest.php @@ -0,0 +1,46 @@ +listener = new InvalidateArticleCacheListener( + $this->cache = new ArrayAdapter(), + ); + } + + /** + * @test + */ + public function it_should_delete_the_cache_entry_when_the_article_is_updated(): void + { + // Arrange + $article = (new ArticleFactory())->create(); + $cacheKey = sprintf(FeedCacheKeys::ARTICLE_WITH_ARTICLE_ID->value, $article->getId()); + + $this->cache->get( + $cacheKey, + fn (ItemInterface $item) => $article, + ); + + // Act + $this->listener->onArticleUpdated(new ArticleUpdatedEvent($article->getId())); + + // Assert + self::assertFalse($this->cache->hasItem($cacheKey)); + } +} diff --git a/tests/Unit/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandlerTest.php b/tests/Unit/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandlerTest.php index d97671c..e175626 100644 --- a/tests/Unit/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandlerTest.php +++ b/tests/Unit/Feed/Application/Query/Article/Handler/LatestUpdatedArticlesHandlerTest.php @@ -6,10 +6,13 @@ use App\Feed\Application\Query\Article\Handler\LatestUpdatedArticlesHandler; use App\Feed\Application\Query\Article\LatestUpdatedArticlesQuery; use App\Feed\Domain\Article\ArticleRepository; +use App\Feed\Infrastructure\Cache\FeedCacheKeys; use DateTime; +use Dev\Common\Infrastructure\Cache\RecordingCache; use Dev\Feed\Factory\ArticleFactory; use Dev\Feed\Repository\InMemoryArticleRepository; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; final class LatestUpdatedArticlesHandlerTest extends TestCase { @@ -17,11 +20,14 @@ final class LatestUpdatedArticlesHandlerTest extends TestCase private ArticleRepository $articleRepository; + private RecordingCache $cache; + public function setUp(): void { $this->articleRepository = new InMemoryArticleRepository(); + $this->cache = new RecordingCache(new ArrayAdapter()); - $this->handler = new LatestUpdatedArticlesHandler($this->articleRepository); + $this->handler = new LatestUpdatedArticlesHandler($this->articleRepository, $this->cache); } /** @@ -119,4 +125,39 @@ public function it_should_return_subset_if_asked_articles_expands_available(): v self::assertEquals($articles[1], ArticleReadModel::fromArticle($article1)); self::assertEquals($articles[2], ArticleReadModel::fromArticle($article2)); } + + /** + * @test + */ + public function it_should_return_results_from_cache(): void + { + // Arrange + $article1 = (new ArticleFactory())->withUpdated(new DateTime('2000-05-20 8:01'))->create(); + $article2 = (new ArticleFactory())->withUpdated(new DateTime('2000-05-20 8:00'))->create(); + + $this->articleRepository->save($article1, $article2); + + $this->cache->get( + sprintf(FeedCacheKeys::ARTICLE_WITH_ARTICLE_ID->value, $article2->getId()), + fn() => ArticleReadModel::fromArticle($article2), + ); + + $query = new LatestUpdatedArticlesQuery(0, 2); + + // Act + $articles = $this->handler->__invoke($query); + + // Assert + self::assertCount(2, $articles); + + self::assertFalse($this->cache->cacheIsHitForKey( + sprintf(FeedCacheKeys::ARTICLE_WITH_ARTICLE_ID->value, $article1->getId()) + )); + self::assertTrue($this->cache->cacheIsHitForKey( + sprintf(FeedCacheKeys::ARTICLE_WITH_ARTICLE_ID->value, $article2->getId()) + )); + + self::assertEquals($articles[0], ArticleReadModel::fromArticle($article1)); + self::assertEquals($articles[1], ArticleReadModel::fromArticle($article2)); + } }