import { describe, it, expect, beforeAll } from 'vitest' import { getPool } from '@/tests/pg/setup' import { MASTER_DATA_DUMP_TABLES, ARCHIVE_COVERED_ELSEWHERE_TABLES, ARCHIVE_EXCLUDED_TABLES, } from '@/lib/reports/full-archive-export' /** * Anti-rot guard for the full-archive backup (BFL 7-year retention). * * The säkerhetsbackup dumps company data table-by-table from an explicit list. * That list rotted once already: salary, assets, dimensions, articles and * rot/rut shipped without ever joining the dump, and three child tables were * queried by a company_id column they do not have (silent error stubs in every * backup). This test makes the schema and the dump contract converge: * * every public base table with a company_id column must be classified as * DUMPED, COVERED ELSEWHERE in the archive, or EXCLUDED with a reason. * * A migration adding a company-scoped table fails here until the author makes * an explicit call about its place in the backup. */ interface ColumnRow { table_name: string column_name: string } let columnsByTable: Map> beforeAll(async () => { const { rows } = await getPool().query(` SELECT c.table_name, c.column_name FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE' `) columnsByTable = new Map() for (const row of rows) { let set = columnsByTable.get(row.table_name) if (!set) { set = new Set() columnsByTable.set(row.table_name, set) } set.add(row.column_name) } }) function companyScopedTables(): string[] { return [...columnsByTable.entries()] .filter(([, cols]) => cols.has('company_id')) .map(([name]) => name) .sort() } describe('full-archive coverage contract', () => { it('classifies every company-scoped table (new tables must be triaged)', () => { const dumped = new Set(MASTER_DATA_DUMP_TABLES.map((t) => t.name)) const covered = new Set(Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES)) const excluded = new Set(Object.keys(ARCHIVE_EXCLUDED_TABLES)) const unclassified = companyScopedTables().filter( (t) => !dumped.has(t) && !covered.has(t) && !excluded.has(t) ) expect( unclassified, `Company-scoped tables missing from the backup contract: ${unclassified.join(', ')}. ` + 'Add each to MASTER_DATA_DUMP_TABLES, ARCHIVE_COVERED_ELSEWHERE_TABLES or ' + 'ARCHIVE_EXCLUDED_TABLES in lib/reports/full-archive-export.ts (with a reason).' ).toEqual([]) }) it('classifies only tables that actually exist (catches renames and drops)', () => { const allClassified = [ ...MASTER_DATA_DUMP_TABLES.map((t) => t.name), ...Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES), ...Object.keys(ARCHIVE_EXCLUDED_TABLES), ] const missing = allClassified.filter((t) => !columnsByTable.has(t)) expect( missing, `Classified tables that no longer exist in the schema: ${missing.join(', ')}` ).toEqual([]) }) it('keeps the three classification buckets disjoint', () => { const dumped = MASTER_DATA_DUMP_TABLES.map((t) => t.name) const covered = Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES) const excluded = Object.keys(ARCHIVE_EXCLUDED_TABLES) const seen = new Set() const duplicates: string[] = [] for (const name of [...dumped, ...covered, ...excluded]) { if (seen.has(name)) duplicates.push(name) seen.add(name) } expect(duplicates).toEqual([]) }) it('dumps direct tables by an existing company_id column', () => { const broken = MASTER_DATA_DUMP_TABLES.filter( (t) => !t.via && !columnsByTable.get(t.name)?.has('company_id') ).map((t) => t.name) expect( broken, `Direct dump tables without a company_id column (the query would error and ` + `the backup would contain an error stub): ${broken.join(', ')}` ).toEqual([]) }) it('pages every dump table by an existing unique key', () => { const broken = MASTER_DATA_DUMP_TABLES.filter( (t) => !columnsByTable.get(t.name)?.has(t.pageKey ?? 'id') ).map((t) => `${t.name}.${t.pageKey ?? 'id'}`) expect(broken, `page keys missing from schema: ${broken.join(', ')}`).toEqual([]) }) it('fetches via-tables through a real parent/fk relationship', () => { for (const t of MASTER_DATA_DUMP_TABLES) { if (!t.via) continue const childCols = columnsByTable.get(t.name) expect(childCols, `via-table ${t.name} does not exist`).toBeDefined() expect( childCols!.has(t.via.fk), `via-table ${t.name} has no column ${t.via.fk}` ).toBe(true) expect( childCols!.has('company_id'), `via-table ${t.name} HAS a company_id column: dump it directly instead` ).toBe(false) expect( columnsByTable.get(t.via.parent)?.has('company_id'), `via-parent ${t.via.parent} of ${t.name} has no company_id column` ).toBe(true) } }) it('denormalizes only parent columns that exist', () => { const broken: string[] = [] for (const t of MASTER_DATA_DUMP_TABLES) { if (!t.denormalize || !t.via) continue const parentCols = columnsByTable.get(t.via.parent) for (const column of t.denormalize.columns) { if (!parentCols?.has(column)) broken.push(`${t.via.parent}.${column}`) } } expect( broken, `denormalize columns missing from the parent schema: ${broken.join(', ')}` ).toEqual([]) }) it('never denormalizes over a real child column', () => { const collisions: string[] = [] for (const t of MASTER_DATA_DUMP_TABLES) { if (!t.denormalize) continue const childCols = columnsByTable.get(t.name) for (const column of t.denormalize.columns) { const key = `${t.denormalize.prefix}${column}` if (childCols?.has(key)) collisions.push(`${t.name}.${key}`) } } expect( collisions, `denormalized keys collide with real child columns (the export keeps the ` + `DB column and silently drops the parent value): ${collisions.join(', ')}` ).toEqual([]) }) it('gives every child of a multi-currency parent its own unit', () => { // A child line row carries money but no currency of its own, so the unit // is unrecoverable from its JSON file alone. Whenever the parent has a // currency column, the child dump must copy it down. const missing: string[] = [] for (const t of MASTER_DATA_DUMP_TABLES) { if (!t.via) continue if (!columnsByTable.get(t.via.parent)?.has('currency')) continue if (!t.denormalize?.columns.includes('currency')) missing.push(t.name) } expect( missing, `child tables whose parent has a currency column but which do not ` + `denormalize it: ${missing.join(', ')}. Add ` + `denormalize: { prefix, columns: ['currency', ...] } in ` + `lib/reports/full-archive-export.ts so the archived line states its unit.` ).toEqual([]) }) it('orders every dump query by columns that exist', () => { const broken = MASTER_DATA_DUMP_TABLES.filter( (t) => t.orderBy && !columnsByTable.get(t.name)?.has(t.orderBy) ).map((t) => `${t.name}.${t.orderBy}`) expect(broken, `orderBy columns missing from schema: ${broken.join(', ')}`).toEqual([]) }) it('writes each table to a unique file', () => { const files = MASTER_DATA_DUMP_TABLES.map((t) => t.file) expect(new Set(files).size).toBe(files.length) }) })