Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 86 additions & 41 deletions src/Repository/DeckRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cols> FROM deck d " — joins, WHERE, ORDER, LIMIT.
*
* @param array<string, mixed> $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
Expand All @@ -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';
Expand All @@ -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<string, mixed>} [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
Expand Down
38 changes: 21 additions & 17 deletions src/State/DeckCollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand All @@ -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<string, mixed> $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;
}
}
128 changes: 128 additions & 0 deletions tests/Api/DeckTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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']])
Expand Down Expand Up @@ -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<string, mixed>
*/
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");
}
}
Loading