diff --git a/src/Repository/DeckRepository.php b/src/Repository/DeckRepository.php index 390f516..c67f623 100644 --- a/src/Repository/DeckRepository.php +++ b/src/Repository/DeckRepository.php @@ -73,31 +73,44 @@ public function findBatchWithCards(int $offset, int $limit): array ->getResult(); } - public function findPublic(int $page, int $itemsPerPage, ?string $hero = null, ?string $cardName = null, string $orderBy = 'created_at', ?string $faction = null, ?string $name = null, ?string $format = null, ?string $cardRef = null): array + /** + * Runs a native SELECT over the deck table, hydrated into Deck entities. + * $sqlTail is everything after "SELECT FROM deck d " — joins, WHERE, ORDER, LIMIT. + * + * @param array $params + * + * @return Deck[] + */ + private function fetchDecks(string $sqlTail, array $params): array { $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addRootEntityFromClassMetadata(Deck::class, 'd'); + $query = $this->getEntityManager()->createNativeQuery( + "SELECT {$rsm->generateSelectClause(['d' => 'd'])} FROM deck d {$sqlTail}", + $rsm, + ); + foreach ($params as $key => $value) { + $query->setParameter($key, $value); + } + + return $query->getResult(); + } + + public function findPublic(int $page, int $itemsPerPage, ?string $hero = null, ?string $cardName = null, string $orderBy = 'created_at', ?string $faction = null, ?string $name = null, ?string $format = null, ?string $cardRef = null): array + { [$join, $where, $params] = $this->buildPublicFilters($hero, $cardName, $faction, $name, $format, $cardRef); $allowedOrderBy = ['created_at', 'upvote_count', 'view_count']; $col = in_array($orderBy, $allowedOrderBy, true) ? $orderBy : 'created_at'; - $sql = "SELECT {$rsm->generateSelectClause(['d' => 'd'])} - FROM deck d {$join} - WHERE {$where} - ORDER BY d.{$col} DESC - LIMIT :limit OFFSET :offset"; - - $query = $this->getEntityManager()->createNativeQuery($sql, $rsm) - ->setParameter('limit', $itemsPerPage) - ->setParameter('offset', ($page - 1) * $itemsPerPage); + $params['limit'] = $itemsPerPage; + $params['offset'] = ($page - 1) * $itemsPerPage; - foreach ($params as $key => $value) { - $query->setParameter($key, $value); - } - - return $query->getResult(); + return $this->fetchDecks( + "{$join} WHERE {$where} ORDER BY d.{$col} DESC LIMIT :limit OFFSET :offset", + $params, + ); } public function countPublic(?string $hero = null, ?string $cardName = null, ?string $faction = null, ?string $name = null, ?string $format = null, ?string $cardRef = null): int @@ -119,20 +132,9 @@ private function buildPublicFilters(?string $hero, ?string $cardName, ?string $f $join = ''; $params = []; - if (null !== $hero) { - // Normalise to faction_number (parts 4+5) so decks with the same hero across - // different sets (CORE/COREKS/BISE) or rarities are all returned. - $parts = explode('_', $hero); - $heroKey = ($parts[3] ?? '').'_'.($parts[4] ?? ''); - $where .= " AND split_part(d.stats->'hero'->>'reference', '_', 4)" - ." || '_' || split_part(d.stats->'hero'->>'reference', '_', 5) = :heroKey"; - $params['heroKey'] = $heroKey; - } - - if (null !== $faction) { - $where .= " AND split_part(d.stats->'hero'->>'reference', '_', 4) = :faction"; - $params['faction'] = $faction; - } + [$heroFactionWhere, $heroFactionParams] = $this->buildHeroFactionWhere($hero, $faction); + $where .= $heroFactionWhere; + $params += $heroFactionParams; if (null !== $cardName) { $join = 'JOIN deck_card dc ON dc.deck_id = d.id'; @@ -159,24 +161,67 @@ private function buildPublicFilters(?string $hero, ?string $cardName, ?string $f return [$join, $where, $params]; } - public function findBgaDecks(?User $user, int $page, int $itemsPerPage, string $name, array $factions, string $hero, string $format, array $validFormats = []): array + /** + * SQL WHERE fragments (each prefixed with " AND ") matching a deck's stored hero + * reference (jsonb) by faction and/or hero. Hero is normalised to faction_number + * (reference parts 4+5) so the same hero across different sets or rarities all match + * — identical semantics to the public listing so a hero picked from the shared hero + * list filters "My Decks" and "Community" the same way. + * + * @return array{0: string, 1: array} [whereFragment, params] + */ + private function buildHeroFactionWhere(?string $hero, ?string $faction): array { - $rsm = new ResultSetMappingBuilder($this->getEntityManager()); - $rsm->addRootEntityFromClassMetadata(Deck::class, 'd'); + $where = ''; + $params = []; + + if (null !== $hero) { + $parts = explode('_', $hero); + $heroKey = ($parts[3] ?? '').'_'.($parts[4] ?? ''); + $where .= " AND split_part(d.stats->'hero'->>'reference', '_', 4)" + ." || '_' || split_part(d.stats->'hero'->>'reference', '_', 5) = :heroKey"; + $params['heroKey'] = $heroKey; + } + + if (null !== $faction) { + $where .= " AND split_part(d.stats->'hero'->>'reference', '_', 4) = :faction"; + $params['faction'] = $faction; + } + + return [$where, $params]; + } + /** + * All decks owned by $user, optionally narrowed by faction and/or hero. + * Faction/hero use the same jsonb matching as the public listing (see + * buildHeroFactionWhere). Ordering matches the client's default sort; the + * client re-sorts on demand. + * + * @return Deck[] + */ + public function findByUser(User $user, ?string $faction = null, ?string $hero = null): array + { + [$heroFactionWhere, $params] = $this->buildHeroFactionWhere($hero, $faction); + $params['userId'] = (string) $user->getId(); + + return $this->fetchDecks( + "WHERE d.user_id = :userId{$heroFactionWhere} ORDER BY d.updated_at DESC", + $params, + ); + } + + public function findBgaDecks(?User $user, int $page, int $itemsPerPage, string $name, array $factions, string $hero, string $format, array $validFormats = []): array + { [$conditions, $params] = $this->buildBgaConditions($user, $name, $factions, $hero, $format, $validFormats); $where = 'WHERE '.implode(' AND ', $conditions); - $sql = "SELECT {$rsm->generateSelectClause(['d' => 'd'])} FROM deck d {$where} ORDER BY d.created_at DESC LIMIT :limit OFFSET :offset"; + $params['limit'] = $itemsPerPage; + $params['offset'] = ($page - 1) * $itemsPerPage; - $query = $this->getEntityManager()->createNativeQuery($sql, $rsm); - foreach ($params as $key => $value) { - $query->setParameter($key, $value); - } - $query->setParameter('limit', $itemsPerPage); - $query->setParameter('offset', ($page - 1) * $itemsPerPage); - - return $query->getResult(); + return $this->fetchDecks( + "{$where} ORDER BY d.created_at DESC LIMIT :limit OFFSET :offset", + $params, + ); } public function countBgaDecks(?User $user, string $name, array $factions, string $hero, string $format, array $validFormats = []): int diff --git a/src/State/DeckCollectionProvider.php b/src/State/DeckCollectionProvider.php index cb08193..e5f3565 100644 --- a/src/State/DeckCollectionProvider.php +++ b/src/State/DeckCollectionProvider.php @@ -4,17 +4,15 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; -use App\Entity\Deck; use App\Entity\User; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\QueryBuilder; +use App\Repository\DeckRepository; use Symfony\Bundle\SecurityBundle\Security; -class DeckCollectionProvider implements ProviderInterface +final readonly class DeckCollectionProvider implements ProviderInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly Security $security, + private DeckRepository $deckRepository, + private Security $security, ) { } @@ -26,20 +24,26 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return []; } - return $this->getQueryBuilder($operation, $context, $currentUser) - ->getQuery() - ->getResult(); + $filters = $context['filters'] ?? []; + + return $this->deckRepository->findByUser( + $currentUser, + faction: $this->stringFilter($filters, 'faction'), + hero: $this->stringFilter($filters, 'hero'), + ); } - private function getQueryBuilder(Operation $operation, array $context, ?User $currentUser): QueryBuilder + /** + * Reads a non-empty scalar string from the API Platform filter context. + * Array-style params (e.g. ?faction[]=x) and empty strings resolve to null + * so they can't reach the string-typed repository method. + * + * @param array $filters + */ + private function stringFilter(array $filters, string $key): ?string { - $qb = $this->em->getRepository(Deck::class)->createQueryBuilder('deck'); - - if ($currentUser) { - $qb->andWhere('deck.user = :user') - ->setParameter('user', $currentUser); - } + $value = $filters[$key] ?? null; - return $qb; + return is_string($value) && '' !== $value ? $value : null; } } diff --git a/tests/Api/DeckTest.php b/tests/Api/DeckTest.php index 79a3051..9be7398 100644 --- a/tests/Api/DeckTest.php +++ b/tests/Api/DeckTest.php @@ -16,6 +16,9 @@ class DeckTest extends WebTestCase protected function setUp(): void { $this->client = static::createClient(); + // Keep a single kernel/container across requests so mocks reconfigured between + // successive requests (e.g. several POSTs each needing different card data) stick. + $this->client->disableReboot(); $this->alteredCoreMock = static::getContainer()->get('altered_core.mock_http_client'); // Default: return empty card list (deck with no cards never triggers HTTP call, // but this prevents MockHttpClient from throwing if called unexpectedly) @@ -90,6 +93,11 @@ private function delete(string $sub, string $id): void private function mockAlteredCore(array $cards): void { + // AlteredCoreClient caches card data per reference in cache.app. With kernel reboot + // disabled (see setUp), the in-process array cache survives across requests within a + // test, so clear it here to guarantee a re-mock of the same reference takes effect. + static::getContainer()->get('cache.app')->clear(); + $json = json_encode($cards); $this->alteredCoreMock->setResponseFactory( static fn (): MockResponse => new MockResponse($json, ['http_code' => 200, 'response_headers' => ['Content-Type: application/json']]) @@ -580,4 +588,124 @@ public function testPublicDecksFilterByCardNameCaseInsensitive(): void $this->assertResponseIsSuccessful(); $this->assertContains($deck['id'], array_column($data['member'], 'id')); } + + // ── My decks: faction / hero filters ─────────────────────────────────────── + + /** + * Creates a non-draft deck whose only card is a hero, so stats.hero.reference + * is populated (the value the faction/hero filters match on). + * + * @return array + */ + private function postHeroDeck(string $sub, string $name, string $heroRef): array + { + $this->mockAlteredCore([[ + 'reference' => $heroRef, + 'name' => 'Hero '.$heroRef, + 'cardType' => ['reference' => 'HERO_MAIN'], + 'faction' => ['code' => explode('_', $heroRef)[3] ?? 'AX'], + 'cardRarity' => ['reference' => 'CORAX_C'], + ]]); + + $deck = $this->post($sub, [ + 'name' => $name, + 'isDraft' => false, + 'deckCards' => [['cardReference' => $heroRef, 'quantity' => 1]], + ]); + $this->assertResponseStatusCodeSame(201); + + return $deck; + } + + private function getMyRaw(string $sub, array $params = []): string + { + $this->client->request('GET', '/api/decks', $params, [], $this->authHeaders($sub)); + + return (string) $this->client->getResponse()->getContent(); + } + + public function testMyDecksFilterByFaction(): void + { + $sub = 'user-'.__FUNCTION__; + $ax = $this->postHeroDeck($sub, 'AX Deck', 'ALT_CORE_B_AX_1_C'); + $ly = $this->postHeroDeck($sub, 'LY Deck', 'ALT_CORE_B_LY_1_C'); + + $body = $this->getMyRaw($sub, ['faction' => 'LY']); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString($ly['id'], $body, 'LY deck should be returned'); + $this->assertStringNotContainsString($ax['id'], $body, 'AX deck should be filtered out'); + + // No filter → both decks returned. + $all = $this->getMyRaw($sub); + $this->assertStringContainsString($ax['id'], $all); + $this->assertStringContainsString($ly['id'], $all); + } + + public function testMyDecksFilterByHeroNormalisesAcrossSets(): void + { + $sub = 'user-'.__FUNCTION__; + // Same hero identity (LY_1) across two different sets, plus a different hero (LY_2). + $ly1core = $this->postHeroDeck($sub, 'LY1 CORE', 'ALT_CORE_B_LY_1_C'); + $ly1bise = $this->postHeroDeck($sub, 'LY1 BISE', 'ALT_BISE_B_LY_1_C'); + $ly2 = $this->postHeroDeck($sub, 'LY2', 'ALT_CORE_B_LY_2_C'); + + $body = $this->getMyRaw($sub, ['hero' => 'ALT_CORE_B_LY_1_C']); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString($ly1core['id'], $body); + $this->assertStringContainsString($ly1bise['id'], $body, 'Same hero across sets should match'); + $this->assertStringNotContainsString($ly2['id'], $body, 'A different hero should be filtered out'); + } + + public function testMyDecksFilterByFactionAndHeroCombined(): void + { + $sub = 'user-'.__FUNCTION__; + $lyMatch = $this->postHeroDeck($sub, 'LY match', 'ALT_CORE_B_LY_1_C'); + $lyOtherHero = $this->postHeroDeck($sub, 'LY other hero', 'ALT_CORE_B_LY_2_C'); + $axSameNumber = $this->postHeroDeck($sub, 'AX same number', 'ALT_CORE_B_AX_1_C'); + + // Both params combine with AND: only the LY deck whose hero is LY_1 matches. + $body = $this->getMyRaw($sub, ['faction' => 'LY', 'hero' => 'ALT_CORE_B_LY_1_C']); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString($lyMatch['id'], $body); + $this->assertStringNotContainsString($lyOtherHero['id'], $body, 'Wrong hero within faction is excluded'); + $this->assertStringNotContainsString($axSameNumber['id'], $body, 'Wrong faction with same hero number is excluded'); + } + + public function testMyDecksFilterByFactionNoMatchReturnsEmpty(): void + { + $sub = 'user-'.__FUNCTION__; + $this->postHeroDeck($sub, 'AX Deck', 'ALT_CORE_B_AX_1_C'); + + // No deck belongs to Muna → empty result, not an error. + $body = $this->getMyRaw($sub, ['faction' => 'MU']); + $this->assertResponseIsSuccessful(); + $this->assertSame('[]', trim($body)); + } + + public function testMyDecksFactionFilterIsCaseSensitive(): void + { + $sub = 'user-'.__FUNCTION__; + $ly = $this->postHeroDeck($sub, 'LY Deck', 'ALT_CORE_B_LY_1_C'); + + // Faction codes are compared with '=' (uppercase, as the client always sends them). + $upper = $this->getMyRaw($sub, ['faction' => 'LY']); + $this->assertStringContainsString($ly['id'], $upper); + + $lower = $this->getMyRaw($sub, ['faction' => 'ly']); + $this->assertStringNotContainsString($ly['id'], $lower, 'Lowercase faction code must not match'); + } + + public function testMyDecksFilterExcludesOtherUsersDecks(): void + { + $owner = 'owner-'.__FUNCTION__; + $other = 'other-'.__FUNCTION__; + $mine = $this->postHeroDeck($owner, 'My LY', 'ALT_CORE_B_LY_1_C'); + $theirs = $this->postHeroDeck($other, 'Their LY', 'ALT_CORE_B_LY_1_C'); + + // Same faction/hero, different owner: the filter must never leak another user's deck. + $body = $this->getMyRaw($owner, ['faction' => 'LY']); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString($mine['id'], $body); + $this->assertStringNotContainsString($theirs['id'], $body, "Another user's deck must not appear"); + } }