Files
LivingDexTracker/tests/unit/combinedDataRepository.test.ts
T
Josh Creek c917bba54a fix(pokedex): open detail cards without a per-card round trip
Since the grid transport change, opening a Pokémon fetched
/api/pokedexes/[id]/entries/[entryId]. That request re-scanned the whole
dex to authorise one entry, on top of an auth call and an ownership
query, and production runs its functions in a different region from the
database — so each card took seconds to fill in. The page's 60-second
reconcile also wiped the detail cache, making cards refetch about once a
minute.

Details are catalog text plus personal notes, so the dex is now read once
per account/dex in the background at the interactive/idle boundary and
fills that cache; a card opens straight from memory. That read reuses the
existing combined-data endpoint, which gains an opt-out for its count
query since a caller taking every row already knows the total.

The per-entry endpoint stays as the fallback for a card opened before the
background read lands, and now checks membership for the single entry
instead of materialising the dex. Catch status still comes from the live
grid row and any queued write, so a cached detail cannot show stale
progress.

The packed grid transport and virtualised rendering are unchanged.
2026-09-18 15:28:55 +01:00

288 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
type Call = { method: string; args: unknown[] };
type TableQuery = { table: string; calls: Call[] };
/**
* Minimal recording stand-in for a Supabase query builder.
*
* Every chained call is recorded and returns the builder, and the builder is thenable so
* `await query` / `await query.range(...)` resolve like a real PostgREST response. Returning
* an empty data set keeps the repository's paging loops to a single iteration.
*/
function createSupabaseStub(
resultFor: (table: string) => unknown = () => ({ data: [], error: null, count: 0 })
) {
const queries: TableQuery[] = [];
const from = (table: string) => {
const record: TableQuery = { table, calls: [] };
queries.push(record);
const builder: Record<string, unknown> = new Proxy(
{},
{
get(_target, prop: string) {
if (prop === 'then') {
return (resolve: (value: unknown) => unknown) => resolve(resultFor(table));
}
return (...args: unknown[]) => {
record.calls.push({ method: prop, args });
return builder;
};
}
}
);
return builder;
};
return { supabase: { from } as never, queries };
}
const queryFor = (queries: TableQuery[], table: string) => queries.filter((q) => q.table === table);
const hasCall = (q: TableQuery, method: string, args: unknown[]) =>
q.calls.some((c) => c.method === method && JSON.stringify(c.args) === JSON.stringify(args));
const mentionsIsDefaultForm = (q: TableQuery) =>
q.calls.some((c) => JSON.stringify(c.args).includes('isDefaultForm'));
describe('CombinedDataRepository base-form filtering', () => {
it('filters to default forms only when the form dex toggle is off', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'user-1', null);
await repo.findAllCombinedData('user-1', false, '', '', []);
const [entries] = queryFor(queries, 'pokedex_entries');
expect(entries).toBeDefined();
expect(hasCall(entries, 'eq', ['isDefaultForm', true])).toBe(true);
});
it('applies no form filter at all when the form dex toggle is on', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'user-1', null);
await repo.findAllCombinedData('user-1', true, '', '', []);
const [entries] = queryFor(queries, 'pokedex_entries');
expect(mentionsIsDefaultForm(entries)).toBe(false);
});
it('filters dex-scoped queries to default forms when the form dex toggle is off', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'user-1', null);
await repo.findAllCombinedData('user-1', false, '', '', ['black-unova']);
const [dexEntries] = queryFor(queries, 'game_pokedex_entry_details');
expect(dexEntries).toBeDefined();
expect(hasCall(dexEntries, 'eq', ['isDefaultForm', true])).toBe(true);
});
it('counts with the same default-form filter the listing uses', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'user-1', null);
await repo.countCombinedData(false, '', '', []);
const [entries] = queryFor(queries, 'pokedex_entries');
expect(hasCall(entries, 'eq', ['isDefaultForm', true])).toBe(true);
});
/**
* Regression guard. game_pokedex_entries is seeded from `form IS NULL` rows, so a default
* form that has a NAME (e.g. Rotom "Lightbulb", Basculin "Red-striped") is absent from the
* game dex tables and can only reach a game-scoped form dex through this supplement query.
*
* Switching this filter to `isDefaultForm` looks like a tidy-up, but it silently drops
* those rows: base Rotom disappeared from the Black form dex while its five appliance
* forms remained. Keep it keyed on `form` - excludeIds already dedupes whatever the dex
* table does list.
*/
it('supplements game forms by form name, never by isDefaultForm', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'user-1', null);
await repo.findAllCombinedData('user-1', true, '', 'Black', ['black-unova']);
const supplements = queryFor(queries, 'pokedex_entries');
expect(supplements.length).toBeGreaterThan(0);
const supplement = supplements[0];
expect(hasCall(supplement, 'not', ['form', 'is', null])).toBe(true);
expect(mentionsIsDefaultForm(supplement)).toBe(false);
});
});
describe('compact grid reads', () => {
const entry = {
id: 1,
pokedexNumber: 1,
pokemon: 'Bulbasaur',
form: null,
spriteKey: '1',
canGigantamax: false
};
it('joins catch flags by ID and retains entries without catches', async () => {
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'catch_records'
? [{ id: 'catch', pokemonId: 1, caught: true, personalNotes: 'private' }]
: [entry, { ...entry, id: 2 }],
error: null
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
const rows = await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
expect(rows[0].catchRecord).toMatchObject({ _id: 'catch', caught: true });
expect(rows[0].catchRecord).not.toHaveProperty('personalNotes');
expect(rows[1].catchRecord).toBeNull();
});
it('deduplicates overlapping scopes without dropping named form supplements', async () => {
const base = { ...entry, pokemon: 'Rotom', form: 'Lightbulb', dexNumber: 1, dexSortOrder: 1 };
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'game_pokedex_entry_details' ? [base, base] : [{ ...base, id: 2, form: 'Heat' }],
error: null
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
const rows = await repo.findGridEntries(true, 'Black', ['one', 'two']);
expect(rows.map((row) => [row.id, row.form])).toEqual([
[1, 'Lightbulb'],
[2, 'Heat']
]);
});
it.each([[[]], [['scope']]])(
'reports entry query failure instead of an empty grid (%j)',
async (scopes) => {
const { supabase } = createSupabaseStub(() => ({
data: null,
error: { message: 'unavailable' }
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await expect(repo.findGridEntries(false, '', scopes)).rejects.toThrow('Unable to load');
}
);
it('reports catch failure instead of displaying everything as uncaught', async () => {
const { supabase } = createSupabaseStub((table) =>
table === 'catch_records'
? { data: null, error: { message: 'unavailable' } }
: { data: [entry], error: null }
);
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await expect(repo.joinGridCatches(await repo.findGridEntries(false, '', []))).rejects.toThrow(
'Unable to load catch records'
);
});
it('keeps a failed detail read distinct from a missing entry', async () => {
const missing = createSupabaseStub(() => ({ data: null, error: null }));
expect(
await new CombinedDataRepository(missing.supabase, 'owner', 'dex').findEntryDetail(1)
).toBeNull();
const failed = createSupabaseStub(() => ({ data: null, error: { message: 'unavailable' } }));
await expect(
new CombinedDataRepository(failed.supabase, 'owner', 'dex').findEntryDetail(1)
).rejects.toThrow('Unable to load entry details');
});
it.each([false, true])(
'returns full instructions with optional catch notes (caught: %s)',
async (caught) => {
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'catch_records'
? caught
? [
{
id: 'catch',
pokemonId: 1,
userId: 'owner',
pokedexId: 'dex',
personalNotes: 'Saved note'
}
]
: []
: { ...entry, catchInformation: 'Full instructions' },
error: null
}));
const result = await new CombinedDataRepository(supabase, 'owner', 'dex').findEntryDetail(1);
expect(result?.pokedexEntry.catchInformation).toBe('Full instructions');
if (caught) expect(result?.catchRecord?.personalNotes).toBe('Saved note');
else expect(result?.catchRecord).toBeNull();
}
);
it('shares a scoped read between simultaneous rows and count requests', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'owner', 'dex');
await Promise.all([
repo.findCombinedData('owner', 1, 30, true, '', 'Scarlet', ['scarlet-paldea']),
repo.countCombinedData(true, '', 'Scarlet', ['scarlet-paldea'])
]);
expect(queryFor(queries, 'game_pokedex_entry_details')).toHaveLength(1);
expect(queryFor(queries, 'pokedex_entries')).toHaveLength(1);
});
it('selects compact columns and does not count the full grid', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
expect(queries).toHaveLength(1);
const selection = queries[0].calls.find((call) => call.method === 'select');
expect(selection?.args[0]).not.toContain('*');
expect(selection?.args[0]).not.toContain('notes');
expect(selection?.args[0]).not.toContain('Information');
expect(selection?.args).toHaveLength(1);
});
});
describe('CombinedDataRepository.isEntryInDex', () => {
it('accepts an entry the scoped dex lists, without reading the whole dex', async () => {
const { supabase, queries } = createSupabaseStub((table) =>
table === 'game_pokedex_entry_details'
? { data: [{ id: 25 }], error: null }
: { data: [], error: null }
);
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(25, false, 'Black', ['black-unova'])).resolves.toBe(true);
const [dexEntries] = queryFor(queries, 'game_pokedex_entry_details');
expect(hasCall(dexEntries, 'eq', ['id', 25])).toBe(true);
expect(hasCall(dexEntries, 'eq', ['isDefaultForm', true])).toBe(true);
expect(hasCall(dexEntries, 'limit', [1])).toBe(true);
// A membership check must not page the dex the way the grid read does.
expect(dexEntries.calls.some((c) => c.method === 'range')).toBe(false);
});
it('accepts a named form a game-scoped form dex supplements from pokedex_entries', async () => {
const { supabase, queries } = createSupabaseStub((table) =>
table === 'pokedex_entries' ? { data: [{ id: 99 }], error: null } : { data: [], error: null }
);
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(99, true, 'Scarlet', ['scarlet-paldea'])).resolves.toBe(true);
const [forms] = queryFor(queries, 'pokedex_entries');
expect(hasCall(forms, 'not', ['form', 'is', null])).toBe(true);
expect(hasCall(forms, 'contains', ['gamesToCatchIn', ['Scarlet']])).toBe(true);
});
it('rejects an entry that is in neither the scoped dex nor its form supplement', async () => {
const { supabase } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(1, true, 'Scarlet', ['scarlet-paldea'])).resolves.toBe(false);
});
it('checks an unscoped dex against the game filter alone', async () => {
const { supabase, queries } = createSupabaseStub(() => ({ data: [{ id: 25 }], error: null }));
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(25, true, 'Red', [])).resolves.toBe(true);
const [entries] = queryFor(queries, 'pokedex_entries');
expect(hasCall(entries, 'contains', ['gamesToCatchIn', ['Red']])).toBe(true);
expect(mentionsIsDefaultForm(entries)).toBe(false);
});
});