From 3f08e1c8997bf60f4fca85a5e60ae10f3d766c25 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Fri, 27 Mar 2026 12:08:06 +0100 Subject: [PATCH 1/5] =?UTF-8?q?fix(monitoring):=20corriger=20le=20schedule?= =?UTF-8?q?r=20qui=20ne=20d=C3=A9tectait=20plus=20les=20nouveaux=20chapitr?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MonitoringSchedule : supprimer la date passée au message (était évaluée une seule fois au démarrage du container, rendant la requête caduque après le premier cycle) - CheckMonitoredMangasHandler : calculer `since` dynamiquement à l'exécution (`new \DateTimeImmutable('-2 hours')`) plutôt que de dépendre du message - AutoScrapingListener : corriger le TypeError silencieux — créer un ScrapingJob avant d'appeler ScrapeChapterHandler (paramètre jobId manquant) Ajoute les tests unitaires CheckMonitoredMangasHandlerTest et AutoScrapingListenerTest. --- .../CheckMonitoredMangasHandler.php | 2 +- .../Scheduler/MonitoringSchedule.php | 4 +- .../EventListener/AutoScrapingListener.php | 11 +- .../CheckMonitoredMangasHandlerTest.php | 110 ++++++++++++++++++ .../AutoScrapingListenerTest.php | 104 +++++++++++++++++ 5 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 tests/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandlerTest.php create mode 100644 tests/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListenerTest.php diff --git a/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php b/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php index abb1b04..2b41f6f 100644 --- a/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php @@ -20,7 +20,7 @@ readonly class CheckMonitoredMangasHandler { $criteria = new MonitoringCriteria( enabled: true, - lastCheckBefore: $command->since ?? new \DateTimeImmutable('-1 hour') + lastCheckBefore: new \DateTimeImmutable('-2 hours') ); $monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria); diff --git a/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php b/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php index 6081e51..b809cc8 100644 --- a/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php +++ b/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php @@ -21,9 +21,7 @@ class MonitoringSchedule implements ScheduleProviderInterface { return (new Schedule())->add( // Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures - RecurringMessage::every('2 hours', new CheckMonitoredMangas( - new \DateTimeImmutable('-2 hours') - )) + RecurringMessage::every('2 hours', new CheckMonitoredMangas()) )->stateful($this->cache); } } diff --git a/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php b/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php index bd4210b..829a349 100644 --- a/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php +++ b/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php @@ -7,6 +7,9 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter; use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; +use App\Domain\Scraping\Domain\Model\ScrapingJob; +use App\Domain\Shared\Domain\Contract\JobRepositoryInterface; +use Ramsey\Uuid\Uuid; use Symfony\Component\Messenger\Attribute\AsMessageHandler; class AutoScrapingListener @@ -15,6 +18,7 @@ class AutoScrapingListener private readonly ScrapeChapterHandler $scrapeChapterHandler, private readonly ChapterRepositoryInterface $chapterRepository, private readonly MangaRepositoryInterface $mangaRepository, + private readonly JobRepositoryInterface $jobRepository, ) { } @@ -25,7 +29,12 @@ class AutoScrapingListener $manga = $this->mangaRepository->getById($chapter->mangaId); if ($manga->isMonitored()) { - $this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue())); + $jobId = Uuid::uuid4()->toString(); + $job = new ScrapingJob($jobId); + $job->context['chapterId'] = $event->chapterId->getValue(); + $this->jobRepository->save($job); + + $this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue(), $jobId)); } } } diff --git a/tests/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandlerTest.php new file mode 100644 index 0000000..53bd168 --- /dev/null +++ b/tests/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandlerTest.php @@ -0,0 +1,110 @@ +mangaRepository = new InMemoryMangaRepository(); + $this->commandBus = new InMemoryMessageBus(); + $this->commandBus->clear(); + $this->handler = new CheckMonitoredMangasHandler($this->mangaRepository, $this->commandBus); + } + + private function createManga(string $id): Manga + { + return new Manga( + new MangaId($id), + new MangaTitle('Manga ' . $id), + new MangaSlug('manga-' . $id), + 'Description', + 'Author', + 2024, + [], + 'ongoing', + new ExternalId('ext-' . $id) + ); + } + + public function testDispatchesRefreshForMonitoredMangaWithOldCheck(): void + { + $manga = $this->createManga('manga-1'); + $manga->enableMonitoring(); + $manga->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours')); + $this->mangaRepository->save($manga); + + $this->handler->handle(new CheckMonitoredMangas()); + + $this->assertTrue($this->commandBus->hasMessageOfType(RefreshMangaChapters::class)); + $dispatched = array_filter( + $this->commandBus->getDispatchedMessages(), + fn ($m) => $m instanceof RefreshMangaChapters + ); + $this->assertCount(1, $dispatched); + $this->assertSame('manga-1', array_values($dispatched)[0]->mangaId->getValue()); + } + + public function testDoesNotDispatchForNonMonitoredManga(): void + { + $manga = $this->createManga('manga-2'); + $this->mangaRepository->save($manga); + + $this->handler->handle(new CheckMonitoredMangas()); + + $this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class)); + } + + public function testDoesNotDispatchForMangaWithRecentCheck(): void + { + $manga = $this->createManga('manga-3'); + $manga->enableMonitoring(); + $manga->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes')); + $this->mangaRepository->save($manga); + + $this->handler->handle(new CheckMonitoredMangas()); + + $this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class)); + } + + public function testDispatchesOnlyMangasWithOldCheck(): void + { + $mangaOld = $this->createManga('manga-old'); + $mangaOld->enableMonitoring(); + $mangaOld->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours')); + $this->mangaRepository->save($mangaOld); + + $mangaRecent = $this->createManga('manga-recent'); + $mangaRecent->enableMonitoring(); + $mangaRecent->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes')); + $this->mangaRepository->save($mangaRecent); + + $mangaDisabled = $this->createManga('manga-disabled'); + $this->mangaRepository->save($mangaDisabled); + + $this->handler->handle(new CheckMonitoredMangas()); + + $dispatched = array_filter( + $this->commandBus->getDispatchedMessages(), + fn ($m) => $m instanceof RefreshMangaChapters + ); + $this->assertCount(1, $dispatched); + $this->assertSame('manga-old', array_values($dispatched)[0]->mangaId->getValue()); + } +} diff --git a/tests/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListenerTest.php b/tests/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListenerTest.php new file mode 100644 index 0000000..7d798d2 --- /dev/null +++ b/tests/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListenerTest.php @@ -0,0 +1,104 @@ +chapterRepository = new InMemoryChapterRepository(); + $this->mangaRepository = new InMemoryMangaRepository(); + $this->mangaRepository->clear(); + $this->jobRepository = new InMemoryJobRepository(); + $this->eventBus = new InMemoryEventBus(); + + $handler = new ScrapeChapterHandler( + new InMemoryScraperFactory(), + new InMemoryImageDownloader(), + new InMemoryImageStorage(), + $this->jobRepository, + $this->chapterRepository, + $this->mangaRepository, + new InMemorySourceRepository(), + $this->eventBus, + ); + + $this->listener = new AutoScrapingListener( + $handler, + $this->chapterRepository, + $this->mangaRepository, + $this->jobRepository, + ); + } + + public function testCreatesJobAndScrapesWhenMangaIsMonitored(): void + { + $chapterId = 'chapter-uuid-1'; + $mangaId = 'manga-monitored'; + + $this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1177.0, null)); + $this->mangaRepository->save(new Manga( + $mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', true + )); + + $this->listener->onChapterReadyForScraping( + new ChapterReadyForScraping(new ChapterId($chapterId)) + ); + + $jobs = $this->jobRepository->findByType('scraping_job'); + $this->assertCount(1, $jobs); + + $job = array_values($jobs)[0]; + $this->assertSame($chapterId, $job->context['chapterId']); + $this->assertInstanceOf(ScrapingJob::class, $job); + + $hasChapterScraped = count(array_filter( + $this->eventBus->getDispatchedMessages(), + fn ($m) => $m instanceof ChapterScraped + )) > 0; + $this->assertTrue($hasChapterScraped); + } + + public function testDoesNothingWhenMangaIsNotMonitored(): void + { + $chapterId = 'chapter-uuid-2'; + $mangaId = 'manga-not-monitored'; + + $this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1176.0, null)); + $this->mangaRepository->save(new Manga( + $mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', false + )); + + $this->listener->onChapterReadyForScraping( + new ChapterReadyForScraping(new ChapterId($chapterId)) + ); + + $this->assertEmpty($this->jobRepository->findByType('scraping_job')); + $this->assertEmpty($this->eventBus->getDispatchedMessages()); + } +} From f42b5a9cf57a7a215ea58958c5da5d425102c83a Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Fri, 27 Mar 2026 14:21:05 +0100 Subject: [PATCH 2/5] =?UTF-8?q?feat(monitoring):=20ajouter=20une=20command?= =?UTF-8?q?e=20console=20pour=20d=C3=A9clencher=20le=20monitoring=20manuel?= =?UTF-8?q?lement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet de tester le scheduler en prod sans attendre le cycle de 2h : make sf c="app:monitoring:run" --- src/Command/RunMonitoringCommand.php | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/Command/RunMonitoringCommand.php diff --git a/src/Command/RunMonitoringCommand.php b/src/Command/RunMonitoringCommand.php new file mode 100644 index 0000000..80c7ce0 --- /dev/null +++ b/src/Command/RunMonitoringCommand.php @@ -0,0 +1,36 @@ +writeln('Déclenchement du monitoring des mangas...'); + + $this->commandBus->dispatch(new CheckMonitoredMangas()); + + $output->writeln('Vérification lancée. Les nouveaux chapitres détectés seront scrappés via le worker commands.'); + + return Command::SUCCESS; + } +} From f47d1a245feed4b89a6c38cc42f13bc7bb4477ef Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Fri, 27 Mar 2026 14:28:30 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix(deploy):=20corriger=20la=20race=20condi?= =?UTF-8?q?tion=20sur=20le=20cache=20prod=20au=20d=C3=A9ploiement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'entrypoint faisait rm -rf var/cache/prod puis lançait FrankenPHP. FrankenPHP compilait partiellement le container DI pendant que le script Deployer lançait aussi cache:clear → fichiers manquants → crash. - entrypoint.sh : ajouter cache:warmup après rm -rf, avant exec FrankenPHP (l'entrypoint est séquentiel, FrankenPHP ne démarre qu'une fois le cache prêt) - deploy.php : supprimer le docker exec cache:clear devenu inutile et dangereux --- deploy.php | 7 +++---- frankenphp/docker-entrypoint.sh | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/deploy.php b/deploy.php index a72efc5..f61ccd3 100644 --- a/deploy.php +++ b/deploy.php @@ -116,14 +116,13 @@ task('webpack_encore:build', function () { sh -c '$installCmd'"); }); -// Restart Docker containers (entrypoint gère les migrations automatiquement) -// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage -// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release. +// Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement) +// Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre, +// ce qui évite la race condition entre FrankenPHP et un docker exec concurrent. desc('Restart Docker containers'); task('docker:restart', function () { run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler'); run('docker restart mangarr'); - run('docker exec mangarr php bin/console cache:clear --env=prod'); }); // Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin diff --git a/frankenphp/docker-entrypoint.sh b/frankenphp/docker-entrypoint.sh index 907822c..f197e41 100755 --- a/frankenphp/docker-entrypoint.sh +++ b/frankenphp/docker-entrypoint.sh @@ -53,11 +53,12 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then fi fi - # Vider le cache prod stale avant le démarrage des workers FrankenPHP. - # Sans ça, les workers chargent l'ancien cache du volume Docker et crashent - # en boucle si les classes du cache ne correspondent plus à la version déployée. + # Vider le cache prod stale et le regénérer AVANT le démarrage de FrankenPHP. + # Sans ça, FrankenPHP et le deploy script compilent le container DI en parallèle + # → fichiers partiellement écrits → crash au démarrage des workers. if [ "$APP_ENV" = "prod" ]; then rm -rf var/cache/prod + php bin/console cache:warmup --env=prod fi setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var From 7e6bacd9341c2af6b57acfa220971124186cdf71 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Fri, 27 Mar 2026 14:34:33 +0100 Subject: [PATCH 4/5] fix(monitoring): ajouter le handler Symfony manquant pour CheckMonitoredMangas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans ce wrapper #[AsMessageHandler], Messenger ne trouvait aucun handler pour le message CheckMonitoredMangas — le scheduler et la commande console échouaient silencieusement avec NoHandlerForMessageException. --- .../SymfonyCheckMonitoredMangasHandler.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/Domain/Manga/Infrastructure/CommandHandler/SymfonyCheckMonitoredMangasHandler.php diff --git a/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyCheckMonitoredMangasHandler.php b/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyCheckMonitoredMangasHandler.php new file mode 100644 index 0000000..5692752 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyCheckMonitoredMangasHandler.php @@ -0,0 +1,21 @@ +handler->handle($command); + } +} From 969f4569f5ca8fe0773a88cb07950d9e173f096d Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Fri, 27 Mar 2026 15:03:05 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(monitoring):=20corriger=20la=20r=C3=A9s?= =?UTF-8?q?olution=20de=20l'ID=20chapitre=20apr=C3=A8s=20synchronisation?= =?UTF-8?q?=20MangaDex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit synchronizeChapters() retournait des UUID temporaires générés en mémoire. Ces UUID n'étant jamais persistés, le Scraping domain ne pouvait pas retrouver le chapitre (SQLSTATE 22P02 : invalid input syntax for type integer). - ChapterSynchronizationServiceInterface : retourne float[] (numéros) au lieu de string[] (UUID) - MangadxChapterSynchronizationService : retourne getNumber() au lieu de getId() - RefreshMangaChaptersHandler : après save(), retrouve chaque chapitre par manga+numéro via findChapterByMangaIdAndNumber() pour obtenir le vrai PK integer avant de dispatcher ChapterReadyForScraping --- .../RefreshMangaChaptersHandler.php | 18 +++++++++++++----- .../ChapterSynchronizationServiceInterface.php | 2 +- .../MangadxChapterSynchronizationService.php | 4 ++-- .../InMemoryChapterSynchronizationService.php | 4 ++-- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php b/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php index 4e58337..67f6702 100644 --- a/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php @@ -26,18 +26,26 @@ readonly class RefreshMangaChaptersHandler throw new \RuntimeException('Manga not found'); } - // Synchronisation + récupération des nouveaux IDs - $newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga); + // Synchronisation + récupération des numéros de nouveaux chapitres + $newChapterNumbers = $this->chapterSynchronizationService->synchronizeChapters($manga); // Mise à jour de la date de monitoring $manga->updateLastMonitoringCheck(new \DateTimeImmutable()); $this->mangaRepository->save($manga); // Événement de scraping pour chaque nouveau chapitre - foreach ($newChapterIds as $chapterId) { - $this->eventBus->dispatch( - new ChapterReadyForScraping(new ChapterId($chapterId)) + // On retrouve l'ID réel (PK integer) après save() car le chapitre n'a + // son identifiant définitif qu'une fois persisté en base. + foreach ($newChapterNumbers as $chapterNumber) { + $saved = $this->mangaRepository->findChapterByMangaIdAndNumber( + $manga->getId()->getValue(), + $chapterNumber ); + if ($saved) { + $this->eventBus->dispatch( + new ChapterReadyForScraping(new ChapterId($saved->getId())) + ); + } } } } diff --git a/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php b/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php index ff44389..1e93473 100644 --- a/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php +++ b/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php @@ -9,7 +9,7 @@ interface ChapterSynchronizationServiceInterface /** * Synchronise les chapitres d'un manga depuis la source externe. * - * @return string[] IDs des nouveaux chapitres ajoutés + * @return float[] Numéros des nouveaux chapitres ajoutés */ public function synchronizeChapters(Manga $manga): array; } diff --git a/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php b/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php index a935770..d76f021 100644 --- a/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php +++ b/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php @@ -96,11 +96,11 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz $newChapterIds = []; - // Sauvegarde uniquement les nouveaux chapitres et collecte leurs IDs + // Sauvegarde uniquement les nouveaux chapitres et collecte leurs numéros foreach ($chaptersByNumber as $chapterNumber => $chapter) { if (!isset($existingChapters[(float) $chapterNumber])) { $manga->addChapter($chapter); - $newChapterIds[] = $chapter->getId(); + $newChapterIds[] = $chapter->getNumber(); } } diff --git a/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php b/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php index 5ce6b30..53a8cd2 100644 --- a/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php +++ b/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php @@ -18,8 +18,8 @@ class InMemoryChapterSynchronizationService implements ChapterSynchronizationSer 'synchronized_at' => new \DateTimeImmutable(), ]; - // Retourne les IDs des chapitres synchronisés (simulation) - return ['chapter-1', 'chapter-2']; + // Retourne les numéros des chapitres synchronisés (simulation) + return [1.0, 2.0]; } /**