mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-14 17:42:17 +00:00
test: replace ad-hoc tests with a layered suite and CI workflow
Splits testing into five layers so a failure points at the responsible one: - tests/unit isolated utility, repository and service tests - tests/data validates the tracked Pokémon, game, region and dex files - tests/integration schema, views, constraints, RLS and repositories - tests/bdd executable Gherkin for user-visible behaviour - tests/build service worker and manifest artifacts per build variant Replaces the two Playwright specs in client-test/ and the two Vitest files in test/. Adds a GitHub Actions workflow running the layers as separate jobs, a mock OAuth provider server so the Drive and Dropbox scenarios never touch real accounts, and a wrapper that reads the local Supabase keys from `supabase status` rather than hard-coding them. Extracts the pure formatting helpers out of PokedexExportService so they can be unit tested, and makes the provider endpoints configurable so the mock server can stand in for Google and Dropbox.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||
const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
|
||||
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
describe('database integrity and ownership', () => {
|
||||
beforeAll(() => {
|
||||
if (!anonKey || !serviceKey) {
|
||||
throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY');
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => {
|
||||
const admin = createClient(url, serviceKey!);
|
||||
const email = `integration-cascade-${Date.now()}@example.test`;
|
||||
const { data: created, error: userError } = await admin.auth.admin.createUser({
|
||||
email,
|
||||
password: 'Integration123!',
|
||||
email_confirm: true
|
||||
});
|
||||
expect(userError).toBeNull();
|
||||
const userId = created.user!.id;
|
||||
const { data: dex, error: dexError } = await admin
|
||||
.from('pokedexes')
|
||||
.insert({ userId, name: 'Cascade', isLivingDex: true })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(dexError).toBeNull();
|
||||
const { data: pokemon } = await admin
|
||||
.from('pokemon')
|
||||
.select('id')
|
||||
.order('id')
|
||||
.limit(1)
|
||||
.single();
|
||||
const record = { userId, pokedexId: dex!.id, pokemonId: pokemon!.id, caught: true };
|
||||
expect((await admin.from('catch_records').insert(record)).error).toBeNull();
|
||||
expect((await admin.from('catch_records').insert(record)).error?.code).toBe('23505');
|
||||
|
||||
expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull();
|
||||
const { count } = await admin
|
||||
.from('catch_records')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('pokedexId', dex!.id);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("does not disclose another user's Pokédex through row-level security", async () => {
|
||||
const admin = createClient(url, serviceKey!);
|
||||
const stamp = Date.now();
|
||||
const password = 'Integration123!';
|
||||
const firstEmail = `integration-owner-${stamp}@example.test`;
|
||||
const secondEmail = `integration-other-${stamp}@example.test`;
|
||||
const first = await admin.auth.admin.createUser({
|
||||
email: firstEmail,
|
||||
password,
|
||||
email_confirm: true
|
||||
});
|
||||
const second = await admin.auth.admin.createUser({
|
||||
email: secondEmail,
|
||||
password,
|
||||
email_confirm: true
|
||||
});
|
||||
expect(first.error).toBeNull();
|
||||
expect(second.error).toBeNull();
|
||||
const { data: dex } = await admin
|
||||
.from('pokedexes')
|
||||
.insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true })
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
const other = createClient(url, anonKey!);
|
||||
expect(
|
||||
(await other.auth.signInWithPassword({ email: secondEmail, password })).error
|
||||
).toBeNull();
|
||||
const { data, error } = await other.from('pokedexes').select('id').eq('id', dex!.id);
|
||||
expect(error).toBeNull();
|
||||
expect(data).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps mapping membership unique for each Pokédex', async () => {
|
||||
const admin = createClient(url, serviceKey!);
|
||||
const email = `integration-mapping-${Date.now()}@example.test`;
|
||||
const created = await admin.auth.admin.createUser({
|
||||
email,
|
||||
password: 'Integration123!',
|
||||
email_confirm: true
|
||||
});
|
||||
const { data: dex } = await admin
|
||||
.from('pokedexes')
|
||||
.insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true })
|
||||
.select('id')
|
||||
.single();
|
||||
const { data: pokemon } = await admin
|
||||
.from('pokemon')
|
||||
.select('id')
|
||||
.order('id')
|
||||
.limit(1)
|
||||
.single();
|
||||
const mapping = { pokedexId: dex!.id, pokemonId: pokemon!.id };
|
||||
expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error).toBeNull();
|
||||
expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error?.code).toBe('23505');
|
||||
});
|
||||
|
||||
it('rejects mapping rows that reference missing records', async () => {
|
||||
const admin = createClient(url, serviceKey!);
|
||||
const { error } = await admin.from('pokedex_pokemon_mapping').insert({
|
||||
pokedexId: '00000000-0000-0000-0000-000000000000',
|
||||
pokemonId: -1
|
||||
});
|
||||
expect(error?.code).toBe('23503');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||
import CombinedDataRepository from '../../src/lib/repositories/CombinedDataRepository';
|
||||
import { readRepoCsv } from '../support/csv';
|
||||
|
||||
/**
|
||||
* Black-box data and repository regressions. These tests deliberately use only the schema
|
||||
* available before the fix: on master they load normally and fail on behavior, while the
|
||||
* fix branch makes the same assertions pass without test-only schema knowledge.
|
||||
*/
|
||||
const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||
const SUPABASE_KEY =
|
||||
process.env.TEST_SUPABASE_ANON_KEY ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0';
|
||||
|
||||
async function requireSupabase() {
|
||||
try {
|
||||
const res = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_entries?select=id&limit=1`, {
|
||||
headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` },
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Local Supabase is required for integration tests at ${SUPABASE_URL}. Run "npm run supabase:start" and "npm run supabase:reset" first. ${String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: number;
|
||||
pokedexNumber: number;
|
||||
pokemon: string;
|
||||
form: string | null;
|
||||
spriteKey: string | null;
|
||||
isDefaultForm: boolean;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
describe('pokedex behavior regressions', () => {
|
||||
let supabase: SupabaseClient;
|
||||
let rows: Row[];
|
||||
|
||||
beforeAll(async () => {
|
||||
await requireSupabase();
|
||||
supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
||||
const all: Row[] = [];
|
||||
for (let from = 0; ; from += 1000) {
|
||||
const { data, error } = await supabase
|
||||
.from('pokedex_entries')
|
||||
.select('id, pokedexNumber, pokemon, form, spriteKey, isDefaultForm, notes')
|
||||
.order('id', { ascending: true })
|
||||
.range(from, from + 999);
|
||||
if (error) throw new Error(error.message);
|
||||
if (!data?.length) break;
|
||||
all.push(...(data as Row[]));
|
||||
if (data.length < 1000) break;
|
||||
}
|
||||
rows = all;
|
||||
});
|
||||
|
||||
it('returns one canonical representative for species whose forms are all named', async () => {
|
||||
const repo = new CombinedDataRepository(supabase, null, null);
|
||||
const entries = (await repo.findAllCombinedData('', false)).map((item) => item.pokedexEntry);
|
||||
|
||||
for (const [species, form] of Object.entries({
|
||||
Basculin: 'Red-striped',
|
||||
Tornadus: 'Incarnate Form',
|
||||
Oricorio: 'Baile (Red)',
|
||||
Zygarde: '50%',
|
||||
Gimmighoul: 'Box Form',
|
||||
Rotom: 'Lightbulb'
|
||||
})) {
|
||||
const found = entries.filter((entry) => entry.pokemon === species);
|
||||
expect(found, `${species} should appear exactly once`).toHaveLength(1);
|
||||
expect(found[0].form).toBe(form);
|
||||
}
|
||||
|
||||
expect(entries.filter((entry) => entry.pokemon === 'Beautifly').map((e) => e.form)).toEqual([
|
||||
'male'
|
||||
]);
|
||||
expect(entries.filter((entry) => entry.pokemon === 'Unown').map((e) => e.form)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('returns every alternate form exactly once when forms are enabled', async () => {
|
||||
const repo = new CombinedDataRepository(supabase, null, null);
|
||||
const entries = (await repo.findAllCombinedData('', true)).map((item) => item.pokedexEntry);
|
||||
|
||||
expect(
|
||||
entries
|
||||
.filter((entry) => entry.pokemon === 'Basculin')
|
||||
.map((entry) => entry.form)
|
||||
.sort()
|
||||
).toEqual(['Blue-striped', 'Red-striped', 'White-striped']);
|
||||
expect(entries.filter((entry) => entry.pokemon === 'Alcremie')).toHaveLength(63);
|
||||
expect(entries.filter((entry) => entry.pokemon === 'Unown')).toHaveLength(28);
|
||||
expect(new Set(entries.map((entry) => entry._id)).size).toBe(entries.length);
|
||||
});
|
||||
|
||||
it('keeps named default forms in a game-scoped form dex without duplicates', async () => {
|
||||
const repo = new CombinedDataRepository(supabase, null, null);
|
||||
const rotom = (await repo.findAllCombinedData('', true, '', 'Black', ['black-unova']))
|
||||
.map((item) => item.pokedexEntry)
|
||||
.filter((entry) => entry.pokemon === 'Rotom');
|
||||
|
||||
expect(rotom.map((entry) => entry.form)).toContain('Lightbulb');
|
||||
expect(rotom).toHaveLength(6);
|
||||
expect(new Set(rotom.map((entry) => entry._id)).size).toBe(rotom.length);
|
||||
});
|
||||
|
||||
it('uses the correct national dex numbers and sprite for corrected rows', () => {
|
||||
const wyrdeer = rows.find((row) => row.pokemon === 'Wyrdeer');
|
||||
expect(wyrdeer).toMatchObject({ pokedexNumber: 899, spriteKey: '899' });
|
||||
expect(rows.find((row) => row.pokemon === 'Gimmighoul')?.pokedexNumber).toBe(999);
|
||||
expect(
|
||||
rows.find((row) => row.pokemon === 'Ursaluna' && row.form === 'Bloodmoon')?.pokedexNumber
|
||||
).toBe(901);
|
||||
});
|
||||
|
||||
it('returns every expected entry despite the PostgREST row cap', async () => {
|
||||
const { calculateExpectedEntries } = await import(
|
||||
'../../src/lib/services/PokedexMappingService'
|
||||
);
|
||||
const baseDex = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
isLivingDex: true,
|
||||
isShinyDex: false,
|
||||
isOriginDex: false,
|
||||
isFormDex: false,
|
||||
gameScope: null,
|
||||
dexScopes: []
|
||||
};
|
||||
|
||||
const baseIds = await calculateExpectedEntries(supabase, baseDex as never);
|
||||
const formIds = await calculateExpectedEntries(supabase, {
|
||||
...baseDex,
|
||||
isFormDex: true
|
||||
} as never);
|
||||
|
||||
expect(baseIds).toHaveLength(1025);
|
||||
expect(new Set(baseIds).size).toBe(baseIds.length);
|
||||
expect(formIds).toHaveLength(rows.length);
|
||||
expect(new Set(formIds).size).toBe(formIds.length);
|
||||
});
|
||||
|
||||
it('keeps the tracked CSV synchronized with the database', () => {
|
||||
const fromCsv = new Map(
|
||||
readRepoCsv('data/csvs/pokemon.csv').map((row) => [
|
||||
`${row.pokemon}|${row.form}`,
|
||||
row.pokedexNumber
|
||||
])
|
||||
);
|
||||
const fromDb = new Map(
|
||||
rows.map((row) => [`${row.pokemon}|${row.form ?? ''}`, String(row.pokedexNumber)])
|
||||
);
|
||||
|
||||
expect({
|
||||
onlyInCsv: [...fromCsv.keys()].filter((key) => !fromDb.has(key)),
|
||||
onlyInDb: [...fromDb.keys()].filter((key) => !fromCsv.has(key)),
|
||||
differing: [...fromCsv.entries()]
|
||||
.filter(([key, value]) => fromDb.has(key) && fromDb.get(key) !== value)
|
||||
.map(([key, value]) => `${key}: csv ${value} vs db ${fromDb.get(key)}`)
|
||||
}).toEqual({ onlyInCsv: [], onlyInDb: [], differing: [] });
|
||||
});
|
||||
|
||||
it('projects exactly one default form per species through the public view', () => {
|
||||
const bySpecies = new Map<string, Row[]>();
|
||||
for (const row of rows)
|
||||
bySpecies.set(row.pokemon, [...(bySpecies.get(row.pokemon) ?? []), row]);
|
||||
const invalid = [...bySpecies]
|
||||
.filter(([, entries]) => entries.filter((entry) => entry.isDefaultForm).length !== 1)
|
||||
.map(([species, entries]) => ({
|
||||
species,
|
||||
defaults: entries.filter((entry) => entry.isDefaultForm).map((entry) => entry.form)
|
||||
}));
|
||||
expect(invalid).toEqual([]);
|
||||
expect(rows[0]).toHaveProperty('notes');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user