diff --git a/tests/Functional/AdminTechnicalControllerCoverageTest.php b/tests/Functional/AdminTechnicalControllerCoverageTest.php new file mode 100644 index 0000000..454b024 --- /dev/null +++ b/tests/Functional/AdminTechnicalControllerCoverageTest.php @@ -0,0 +1,246 @@ +client->getSession(); + self::assertNotNull($session, 'Session must exist — make a GET request first.'); + $session->start(); + + /** @var \Symfony\Component\HttpFoundation\RequestStack $requestStack */ + $requestStack = static::getContainer()->get('request_stack'); + + $synthetic = new \Symfony\Component\HttpFoundation\Request(); + $synthetic->setSession($session); + $requestStack->push($synthetic); + + try { + /** @var \Symfony\Component\Security\Csrf\CsrfTokenManagerInterface $tokenManager */ + $tokenManager = static::getContainer()->get('security.csrf.token_manager'); + + return $tokenManager->getToken($tokenId)->getValue(); + } finally { + $requestStack->pop(); + } + } + + public function testEnrichRetryRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/enrich-retry', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testEnrichRetrySucceedsWithValidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/enrich-retry', [ + '_token' => $this->getCsrfToken('technical-enrich-retry'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + // Either info (none pending) or success (dispatched). + self::assertSelectorExists('.alert-info, .alert-success'); + } + + public function testFlushReenrichRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/flush-reenrich', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + // Note: flushAndReenrich, setMappingsRebuild, tcgdexSyncInsert, and + // tcgdexSyncUpdate dispatch Messenger messages on transports that are + // configured `sync://` in the test env. The handlers reach external + // services (TCGdex / PokeAPI) and the controller doesn't catch handler + // exceptions, so happy-path tests for these actions are inherently flaky. + // We cover the auth + CSRF-rejection branches only. + + public function testMosaicGenerateRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/mosaic-generate', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testMosaicGenerateSucceedsWithValidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/mosaic-generate', [ + '_token' => $this->getCsrfToken('technical-mosaic-generate'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + // Either info (0 pending) or success (N dispatched) — both branches of mosaicGenerate. + self::assertSelectorExists('.alert-info, .alert-success'); + } + + public function testSpriteMappingRebuildRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/sprite-mapping-rebuild', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testTcgdexSyncInsertRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/tcgdex-sync-insert', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testTcgdexSyncUpdateRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/tcgdex-sync-update', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testBannedCardsSyncRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/banned-cards-sync', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearCacheRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/clear-cache', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearCacheInvalidatesMenuRuntime(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-cache', [ + '_token' => $this->getCsrfToken('technical-clear-cache'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testClearAppCacheRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/clear-app-cache', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearAppCacheClearsCachePool(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-app-cache', [ + '_token' => $this->getCsrfToken('technical-clear-app-cache'), + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } + + public function testClearCacheKeyRejectsInvalidCsrf(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('POST', '/admin/technical/clear-cache-key', ['_token' => 'wrong']); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + } + + public function testClearCacheKeyShowsWarningOnEmptyInput(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-cache-key', [ + '_token' => $this->getCsrfToken('technical-clear-cache-key'), + 'cache_key' => ' ', + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-warning'); + } + + public function testClearCacheKeyDeletesNamedKey(): void + { + $this->loginAs('admin@example.com'); + $this->client->request('GET', '/admin/technical'); + + $this->client->request('POST', '/admin/technical/clear-cache-key', [ + '_token' => $this->getCsrfToken('technical-clear-cache-key'), + 'cache_key' => 'test_cache_key', + ]); + + self::assertResponseRedirects('/admin/technical'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-success'); + } +} diff --git a/tests/Functional/DeckShowPdfRoutesTest.php b/tests/Functional/DeckShowPdfRoutesTest.php new file mode 100644 index 0000000..96e7c4c --- /dev/null +++ b/tests/Functional/DeckShowPdfRoutesTest.php @@ -0,0 +1,137 @@ +loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label.pdf', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', (string) $this->client->getResponse()->getContent()); + } + + public function testLabelPdfDeniedForNonOwner(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label.pdf', $shortTag)); + + self::assertResponseStatusCodeSame(403); + } + + public function testLabelFoldablePdfReturnsPdfForOwner(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label-foldable.pdf', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', (string) $this->client->getResponse()->getContent()); + } + + public function testLabelFoldablePdfDeniedForNonOwner(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/label-foldable.pdf', $shortTag)); + + self::assertResponseStatusCodeSame(403); + } + + public function testDecklistPdfReturnsPersonalPdfForOwner(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/decklist.pdf', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', (string) $this->client->getResponse()->getContent()); + } + + public function testDecklistPdfReturnsAnonymousVariantWithQueryFlag(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/decklist.pdf?anonymous=1', $shortTag)); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + } + + public function testDecklistPdfDeniedForNonOwner(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('GET', \sprintf('/deck/%s/decklist.pdf', $shortTag)); + + self::assertResponseStatusCodeSame(403); + } + + public function testReEnrichRequiresTechnicalAdminRole(): void + { + $this->loginAs('borrower@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('POST', \sprintf('/deck/%s/re-enrich', $shortTag), ['_token' => 'irrelevant']); + + self::assertResponseStatusCodeSame(403); + } + + public function testReEnrichRequiresValidCsrf(): void + { + $this->loginAs('admin@example.com'); + + $shortTag = $this->getDeckShortTag('Iron Thorns'); + $this->client->request('POST', \sprintf('/deck/%s/re-enrich', $shortTag), ['_token' => 'wrong']); + + // Invalid CSRF -> AccessDeniedException -> 403. + self::assertResponseStatusCodeSame(403); + } + + private function getDeckShortTag(string $name): string + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + $deck = $em->getRepository(Deck::class)->findOneBy(['name' => $name]); + \assert($deck instanceof Deck); + + return $deck->getShortTag(); + } +} diff --git a/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php b/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php index 0d4b8e5..d41d71f 100644 --- a/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php +++ b/tests/MessageHandler/GenerateMinifiedMosaicHandlerTest.php @@ -270,4 +270,230 @@ public function testTilesCarryCardPrintingForFallbackResolution(): void self::assertSame(2, $tile->quantity); self::assertSame($printing, $tile->printing, 'Tile must carry the CardPrinting for fallback-aware image resolution'); } + + /** + * The catch-block re-raises after logging — exercises the error + * branch and the rethrow. + */ + public function testExceptionInPipelineIsLoggedAndRethrown(): void + { + $card = new DeckCard(); + $card->setCardName('Pikachu'); + $card->setSetCode('BRS'); + $card->setCardNumber('50'); + $card->setCardType('pokemon'); + $card->setQuantity(2); + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + $version->addCard($card); + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willThrowException(new \RuntimeException('disk full')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $this->createStub(MosaicUrlResolver::class), + $this->createStub(CardPrintingRepository::class), + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $logger, + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('disk full'); + + $handler(new GenerateMinifiedMosaicMessage(1)); + } + + /** + * MINIFIED_PRINTING_OVERRIDES short-circuits the resolution chain with + * a static URL and no CardPrinting. + */ + public function testStaticOverrideShortCircuitsImageResolution(): void + { + // GEN|73 is in DeckListParser::MINIFIED_PRINTING_OVERRIDES. + $card = new DeckCard(); + $card->setCardName('Energy Switch'); + $card->setSetCode('GEN'); + $card->setCardNumber('73'); + $card->setCardType('trainer'); + $card->setQuantity(4); + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + $version->addCard($card); + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + // The override should mean the printingRepository is never asked. + $printingRepository = $this->createMock(CardPrintingRepository::class); + $printingRepository->expects(self::never())->method('findLowestRarityForIdentity'); + + $capturedTiles = null; + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willReturnCallback(static function ($v, $tiles) use (&$capturedTiles): string { + $capturedTiles = $tiles; + + return 'mosaic/1/1_minified.webp'; + }); + + $mosaicUrlResolver = $this->createStub(MosaicUrlResolver::class); + $mosaicUrlResolver->method('resolveForVersion')->willReturn('https://example.com/mosaic.webp'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $mosaicUrlResolver, + $printingRepository, + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $this->createStub(LoggerInterface::class), + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $handler(new GenerateMinifiedMosaicMessage(1)); + + self::assertNotNull($capturedTiles); + self::assertCount(1, $capturedTiles); + // Static override: tile.printing is null and image URL is from the override map. + self::assertNull($capturedTiles[0]->printing); + self::assertNotNull($capturedTiles[0]->imageUrl); + } + + /** + * Cards sharing the same name + image URL collapse into one tile with + * summed quantity. This is the de-dup path in buildMergedTiles. + */ + public function testTilesWithSameImageAndNameMergeWithSummedQuantity(): void + { + // Two cards with the same name + same override image collapse into one. + $cardA = new DeckCard(); + $cardA->setCardName('Energy Switch'); + $cardA->setSetCode('GEN'); + $cardA->setCardNumber('73'); + $cardA->setCardType('trainer'); + $cardA->setQuantity(2); + + $cardB = new DeckCard(); + $cardB->setCardName('Energy Switch'); + $cardB->setSetCode('GEN'); + $cardB->setCardNumber('73'); + $cardB->setCardType('trainer'); + $cardB->setQuantity(2); + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + $version->addCard($cardA); + $version->addCard($cardB); + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + $capturedTiles = null; + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willReturnCallback(static function ($v, $tiles) use (&$capturedTiles): string { + $capturedTiles = $tiles; + + return 'mosaic/1/1_minified.webp'; + }); + + $mosaicUrlResolver = $this->createStub(MosaicUrlResolver::class); + $mosaicUrlResolver->method('resolveForVersion')->willReturn('https://example.com/mosaic.webp'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $mosaicUrlResolver, + $this->createStub(CardPrintingRepository::class), + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $this->createStub(LoggerInterface::class), + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $handler(new GenerateMinifiedMosaicMessage(1)); + + self::assertNotNull($capturedTiles); + self::assertCount(1, $capturedTiles, 'Two cards with same name + image must merge into one tile.'); + self::assertSame(4, $capturedTiles[0]->quantity, 'Merged tile carries the summed quantity.'); + } + + /** + * Tile sort order: pokemon (qty desc, name asc) → trainer (no subtype + * differentiation here) → energy. Sets the subtype-based ordering + * branch via the cardType key alone since trainerSubtype is computed + * from CardPrinting -> CardIdentity (out of scope for this assertion). + */ + public function testTilesAreSortedByTypeThenQuantityThenName(): void + { + $cards = [ + $this->buildSimpleCard('Lightning', 'energy', 5, 'BRS', 'E1'), + $this->buildSimpleCard('Switch', 'trainer', 4, 'BRS', '60'), + $this->buildSimpleCard('Boss', 'trainer', 2, 'BRS', '50'), + $this->buildSimpleCard('Charizard', 'pokemon', 2, 'BRS', '20'), + $this->buildSimpleCard('Bulbasaur', 'pokemon', 4, 'BRS', '10'), + ]; + + $version = new DeckVersion(); + $version->setEnrichmentStatus('done'); + foreach ($cards as $card) { + $version->addCard($card); + } + + $versionRepository = $this->createStub(DeckVersionRepository::class); + $versionRepository->method('find')->willReturn($version); + + $capturedTiles = null; + $mosaicGenerator = $this->createStub(MosaicGenerator::class); + $mosaicGenerator->method('generateFromTiles')->willReturnCallback(static function ($v, $tiles) use (&$capturedTiles): string { + $capturedTiles = $tiles; + + return 'mosaic/1/1_minified.webp'; + }); + + $mosaicUrlResolver = $this->createStub(MosaicUrlResolver::class); + $mosaicUrlResolver->method('resolveForVersion')->willReturn('https://example.com/mosaic.webp'); + + $handler = new GenerateMinifiedMosaicHandler( + $mosaicGenerator, + $mosaicUrlResolver, + $this->createStub(CardPrintingRepository::class), + $this->createStub(CardIdentityResolver::class), + $versionRepository, + $this->createStub(EntityManagerInterface::class), + $this->createStub(LoggerInterface::class), + $this->createStub(MinifiedCardViewBuilder::class), + ); + + $handler(new GenerateMinifiedMosaicMessage(1)); + + self::assertNotNull($capturedTiles); + $names = array_map(static fn (MosaicTile $t): string => $t->cardName, $capturedTiles); + // Type order pokemon → trainer → energy. + // Within pokemon: qty desc (4 Bulbasaur, 2 Charizard). + // Within trainer: qty desc (4 Switch, 2 Boss). + self::assertSame(['Bulbasaur', 'Charizard', 'Switch', 'Boss', 'Lightning'], $names); + } + + private function buildSimpleCard(string $name, string $type, int $quantity, string $setCode, string $number): DeckCard + { + $card = new DeckCard(); + $card->setCardName($name); + $card->setCardType($type); + $card->setQuantity($quantity); + $card->setSetCode($setCode); + $card->setCardNumber($number); + + return $card; + } }