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:
Josh Creek
2026-09-13 17:35:04 +01:00
parent 08c5e3271c
commit de39dc78ea
52 changed files with 3390 additions and 397 deletions
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
import { getOptionalUserId, requireAuth } from '../../src/lib/utils/auth';
function eventReturning(value: unknown) {
return { locals: { safeGetSession: vi.fn(async () => value) } } as never;
}
describe('authentication guards', () => {
it('returns the authenticated user id', async () => {
await expect(
requireAuth(eventReturning({ session: {}, user: { id: 'user-1' } }))
).resolves.toBe('user-1');
});
it.each([
{ session: null, user: null },
{ session: {}, user: null },
{ session: null, user: { id: 'user-1' } }
])('rejects an incomplete authenticated session', async (value) => {
await expect(requireAuth(eventReturning(value))).rejects.toMatchObject({ status: 401 });
});
it('optionally returns a user id or null', async () => {
await expect(
getOptionalUserId(eventReturning({ session: {}, user: { id: 'user-2' } }))
).resolves.toBe('user-2');
await expect(
getOptionalUserId(eventReturning({ session: null, user: null }))
).resolves.toBeNull();
const throwing = {
locals: { safeGetSession: vi.fn(async () => Promise.reject(new Error('unavailable'))) }
} as never;
await expect(getOptionalUserId(throwing)).resolves.toBeNull();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { calculateBoxNumbers, calculateBoxPlacement } from '../../src/lib/utils/boxPlacement';
describe('box placement', () => {
it.each([
[0, { box: 1, row: 1, column: 1 }],
[5, { box: 1, row: 1, column: 6 }],
[6, { box: 1, row: 2, column: 1 }],
[29, { box: 1, row: 5, column: 6 }],
[30, { box: 2, row: 1, column: 1 }]
])('places zero-based entry %i in its box grid', (index, expected) => {
expect(calculateBoxPlacement(index)).toEqual(expected);
});
it.each([
[0, []],
[1, [1]],
[30, [1]],
[31, [1, 2]],
[1025, Array.from({ length: 35 }, (_, index) => index + 1)]
])('calculates box numbers for %i entries', (count, expected) => {
expect(calculateBoxNumbers(count)).toEqual(expected);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createCatchRecordWriteQueue } from '../../src/lib/utils/catchRecordWriteQueue';
import type { CatchRecord } from '../../src/lib/models/CatchRecord';
function mkRecord(overrides: Partial<CatchRecord> = {}): CatchRecord {
return {
_id: '',
userId: 'u1',
pokedexId: 'p1',
pokemonId: '25',
haveToEvolve: false,
caught: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: '',
...overrides
};
}
describe('createCatchRecordWriteQueue()', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.restoreAllMocks();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('coalesces multiple updates for the same key and flushes only the latest state', async () => {
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
return new Response(init?.body as string, { status: 200 });
});
const queue = createCatchRecordWriteQueue({
endpointUrl: '/api/pokedexes/p1/catch-records',
fetchFn,
batchSize: 50,
concurrency: 1
});
queue.enqueue(mkRecord({ caught: true }));
queue.enqueue(mkRecord({ caught: false, haveToEvolve: true }));
await queue.flushNow();
expect(fetchFn).toHaveBeenCalledTimes(1);
const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body));
expect(body).toHaveLength(1);
expect(body[0].haveToEvolve).toBe(true);
expect(body[0].caught).toBe(false);
});
it('debounces notes updates before flushing', async () => {
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
return new Response(init?.body as string, { status: 200 });
});
const queue = createCatchRecordWriteQueue({
endpointUrl: '/api/pokedexes/p1/catch-records',
fetchFn,
batchSize: 50,
concurrency: 1
});
queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 });
await queue.flushNow();
expect(fetchFn).toHaveBeenCalledTimes(0);
vi.advanceTimersByTime(499);
await queue.flushNow();
expect(fetchFn).toHaveBeenCalledTimes(0);
vi.advanceTimersByTime(1);
await queue.flushNow();
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it('reports failures, clears errors, and retries after exponential backoff', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(new Response('temporarily unavailable', { status: 503 }))
.mockResolvedValueOnce(new Response('[]', { status: 200 }));
const queue = createCatchRecordWriteQueue({
endpointUrl: '/catch-records',
fetchFn
});
let status = null as null | {
pending: number;
lastError: string | null;
lastSuccessfulFlushAt: number | null;
};
const unsubscribe = queue.getStatus.subscribe((value) => (status = value));
queue.enqueue(mkRecord(), { flushSoon: false });
expect(queue.getPendingCount()).toBe(1);
await queue.flushNow();
expect(status?.lastError).toContain('503 temporarily unavailable');
expect(queue.getPendingCount()).toBe(1);
queue.clearError();
expect(status?.lastError).toBeNull();
await vi.advanceTimersByTimeAsync(250);
await queue.flushNow();
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(queue.getPendingCount()).toBe(0);
expect(status?.lastSuccessfulFlushAt).not.toBeNull();
unsubscribe();
});
it('honours batch limits and keepalive while draining eligible records', async () => {
const fetchFn = vi.fn<typeof fetch>(async () => new Response('[]', { status: 200 }));
const queue = createCatchRecordWriteQueue({
endpointUrl: '/catch-records',
fetchFn,
batchSize: 50
});
queue.enqueue(mkRecord({ pokemonId: '1' }), { flushSoon: false });
queue.enqueue(mkRecord({ pokemonId: '2' }), { flushSoon: false });
queue.enqueue(mkRecord({ pokemonId: '3' }), { flushSoon: false });
await queue.flushNow({ limit: 2, keepalive: true });
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body))).toHaveLength(2);
expect(JSON.parse(String(fetchFn.mock.calls[1][1]?.body))).toHaveLength(1);
expect(fetchFn.mock.calls.every(([, init]) => init?.keepalive === true)).toBe(true);
});
it('retains work while the browser is offline', async () => {
vi.stubGlobal('navigator', { onLine: false });
const fetchFn = vi.fn<typeof fetch>();
const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn });
queue.enqueue(mkRecord(), { flushSoon: false });
await queue.flushNow();
expect(fetchFn).not.toHaveBeenCalled();
expect(queue.getPendingCount()).toBe(1);
});
it('does not discard a newer version enqueued during an in-flight request', async () => {
let resolveFirst: ((response: Response) => void) | undefined;
const firstResponse = new Promise<Response>((resolve) => (resolveFirst = resolve));
const fetchFn = vi
.fn<typeof fetch>()
.mockReturnValueOnce(firstResponse)
.mockResolvedValueOnce(new Response('[]', { status: 200 }));
const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn });
queue.enqueue(mkRecord({ personalNotes: 'old' }), { flushSoon: false });
const flushing = queue.flushNow();
await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
queue.enqueue(mkRecord({ personalNotes: 'new' }), { flushSoon: false });
resolveFirst?.(new Response('[]', { status: 200 }));
await flushing;
expect(fetchFn).toHaveBeenCalledTimes(2);
const latest = JSON.parse(String(fetchFn.mock.calls[1][1]?.body));
expect(latest[0].personalNotes).toBe('new');
expect(queue.getPendingCount()).toBe(0);
});
it('stores non-Error failures as readable status text', async () => {
const fetchFn = vi.fn<typeof fetch>(async () => {
throw 'network down';
});
const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn });
let lastError: string | null = null;
queue.getStatus.subscribe((status) => (lastError = status.lastError));
queue.enqueue(mkRecord(), { flushSoon: false });
await queue.flushNow();
expect(lastError).toBe('network down');
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import CombinedDataRepository from '../../src/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() {
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({ data: [], error: null, count: 0 });
}
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);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import {
clearOAuthStateCookie,
createOAuthState,
readOAuthStateCookie,
setOAuthStateCookie,
type OAuthStatePayload
} from '../../src/lib/utils/oauthState';
function eventWithCookie(raw?: string) {
return {
url: new URL('https://example.test/callback'),
cookies: {
get: vi.fn(() => raw),
set: vi.fn(),
delete: vi.fn()
}
} as never;
}
describe('OAuth state cookies', () => {
it('creates opaque unique state values', () => {
const first = createOAuthState();
const second = createOAuthState();
expect(first).toMatch(/^[0-9a-f-]{36}$/);
expect(second).not.toBe(first);
});
it('writes a secure, short-lived, provider-scoped cookie', () => {
const event = eventWithCookie();
const payload: OAuthStatePayload = {
state: 'state-1',
userId: 'user-1',
provider: 'google_drive',
returnTo: '/backup-settings'
};
setOAuthStateCookie(event, 'google_drive', payload);
expect(event.cookies.set).toHaveBeenCalledWith(
'oauth_state_google_drive',
JSON.stringify(payload),
expect.objectContaining({ httpOnly: true, sameSite: 'lax', secure: true, maxAge: 600 })
);
});
it('reads only structurally valid state', () => {
expect(readOAuthStateCookie(eventWithCookie('{bad json'), 'dropbox')).toBeNull();
expect(readOAuthStateCookie(eventWithCookie('{}'), 'dropbox')).toBeNull();
expect(readOAuthStateCookie(eventWithCookie(), 'dropbox')).toBeNull();
expect(
readOAuthStateCookie(
eventWithCookie(JSON.stringify({ state: 's', userId: 'u', provider: 'dropbox' })),
'dropbox'
)
).toMatchObject({ state: 's', userId: 'u' });
});
it('clears the provider cookie at the shared path', () => {
const event = eventWithCookie();
clearOAuthStateCookie(event, 'dropbox');
expect(event.cookies.delete).toHaveBeenCalledWith('oauth_state_dropbox', { path: '/' });
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildCsv,
csvEscape,
sanitizeFileName,
shouldRefreshToken
} from '../../src/lib/services/PokedexExportFormatting';
describe('Pokédex export formatting', () => {
it.each([
[null, ''],
[undefined, ''],
['plain', 'plain'],
['comma,value', '"comma,value"'],
['a "quote"', '"a ""quote"""'],
['two\nlines', '"two\nlines"']
])('escapes CSV value %j', (value, expected) => {
expect(csvEscape(value)).toBe(expected);
});
it('sanitizes provider filenames while preserving a CSV suffix', () => {
expect(sanitizeFileName(' My: Dex? ', 'fallback')).toBe('My- Dex-.csv');
expect(sanitizeFileName('already.csv', 'fallback')).toBe('already.csv');
expect(sanitizeFileName('***', 'fallback')).toBe('-.csv');
expect(sanitizeFileName(' ', 'fallback')).toBe('fallback');
});
it('builds a stable, escaped CSV with defaults for missing catch records', () => {
const csv = buildCsv(
{ _id: 'dex-1', name: 'Test' } as never,
[
{
pokedexEntry: {
_id: '25',
pokedexNumber: 25,
pokemon: 'Pikachu',
form: null
},
catchRecord: {
caught: true,
haveToEvolve: false,
inHome: true,
hasGigantamaxed: false,
personalNotes: 'Comma, and "quote"'
}
},
{
pokedexEntry: {
_id: '26',
pokedexNumber: 26,
pokemon: 'Raichu',
form: 'Alolan'
},
catchRecord: null
}
] as never
);
expect(csv.split('\r\n')).toEqual([
'pokemonId,pokedexNumber,pokemon,form,caught,haveToEvolve,inHome,personalNotes',
'25,25,Pikachu,,true,false,true,"Comma, and ""quote"""',
'26,26,Raichu,Alolan,false,false,false,'
]);
});
it('refreshes only finite expiries within the next minute', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-13T12:00:00Z'));
expect(shouldRefreshToken(null)).toBe(false);
expect(shouldRefreshToken('not-a-date')).toBe(false);
expect(shouldRefreshToken('2026-09-13T12:02:00Z')).toBe(false);
expect(shouldRefreshToken('2026-09-13T12:00:30Z')).toBe(true);
expect(shouldRefreshToken('2026-09-13T11:59:00Z')).toBe(true);
vi.useRealTimers();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import {
getRegionalDexColumnName,
getRegionalDexFieldName,
getRegionalDexKey,
hasRegionalDex
} from '../../src/lib/utils/regionalDexMapping';
describe('regional dex mapping', () => {
it.each([
['Red', 'kanto', 'kanto_dex_number', 'kantoDexNumber'],
['Black2', 'unova_b2w2', 'unova_b2w2_dex_number', 'unovaB2w2DexNumber'],
['UltraMoon', 'alola_usum', 'alola_usum_dex_number', 'alolaUsumDexNumber'],
['Scarlet', 'paldea', 'paldea_dex_number', 'paldeaDexNumber']
])('maps %s consistently', (game, key, column, field) => {
expect(hasRegionalDex(game)).toBe(true);
expect(getRegionalDexKey(game)).toBe(key);
expect(getRegionalDexColumnName(game)).toBe(column);
expect(getRegionalDexFieldName(game)).toBe(field);
});
it('returns no mapping for unknown games', () => {
expect(hasRegionalDex('Legends Z-A')).toBe(false);
expect(getRegionalDexKey('Legends Z-A')).toBeUndefined();
expect(getRegionalDexColumnName('Legends Z-A')).toBeUndefined();
expect(getRegionalDexFieldName('Legends Z-A')).toBeUndefined();
});
});