// Generated, do not edit. sha256:ae56ded82e34b23f4f7847c4b9b685f528acb7506919a3b47bd8b4b374a57bed import { describe, it, before } from 'node:test'; import assert from 'node:assert/strict'; import { SCHEMA as BASELINE_SCHEMA } from '../../server/db/schema.js'; import { openBaselineDb, foreignKeysAan, vulFixture, FIXTURE_TABELLEN } from '../helpers/baseline-db.mjs'; import { CONFIG, ENUMS, CAPABILITIES, SOURCE_CAPABILITIES, AUTH_KINDS, OUTPUT_FIELDS, TRANSFORMS, HTTP_METHODS } from '../../shared/config.js'; let db, TABELLEN, KOLOMMEN, INDEXEN, FKS; before(() => { db = openBaselineDb(); TABELLEN = db.prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name` ).all().map(r => r.name); KOLOMMEN = new Map(); INDEXEN = new Map(); FKS = new Map(); for (const t of TABELLEN) { KOLOMMEN.set(t, db.prepare(`PRAGMA table_info(${t})`).all()); FKS.set(t, db.prepare(`PRAGMA foreign_key_list(${t})`).all()); INDEXEN.set(t, db.prepare(`PRAGMA index_list(${t})`).all().map(ix => ({ ...ix, kolommen: db.prepare(`PRAGMA index_info(${ix.name})`).all().map(k => k.name), }))); } }); const woorden = (naam) => naam.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(' '); describe('baseline: het schema laadt en is compleet', () => { it('maakt achtendertig tabellen aan', () => { assert.equal(TABELLEN.length, 45, `gevonden: ${TABELLEN.join(', ')}`); }); it('heeft voor elke tabel een fixture-rij', () => { assert.deepEqual([...FIXTURE_TABELLEN].sort(), [...TABELLEN].sort()); }); it('noemt elke tabel in de data-viewer, en geen tabel die niet bestaat', () => { assert.deepEqual([...CONFIG.DB_TABLES].sort(), [...TABELLEN].sort()); }); it('kan elke tabel vullen', () => { const eigen = openBaselineDb(); vulFixture(eigen); for (const t of TABELLEN) { assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n, 1, `${t} is leeg`); } }); }); describe('baseline: de naamconventie', () => { const MEERVOUD = /(?:s|ies|es)$/i; const MEERVOUD_UITZONDERING = new Set(['address', 'status']); const GERESERVEERD = new Set(['abort', 'action', 'add', 'after', 'all', 'alter', 'and', 'as', 'asc', 'attach', 'autoincrement', 'before', 'begin', 'between', 'by', 'cascade', 'case', 'cast', 'check', 'collate', 'column', 'commit', 'conflict', 'constraint', 'create', 'cross', 'default', 'deferrable', 'deferred', 'delete', 'desc', 'detach', 'distinct', 'drop', 'each', 'else', 'end', 'escape', 'except', 'exclusive', 'exists', 'explain', 'fail', 'for', 'foreign', 'from', 'full', 'group', 'having', 'if', 'ignore', 'immediate', 'in', 'index', 'indexed', 'initially', 'inner', 'insert', 'instead', 'intersect', 'into', 'is', 'isnull', 'join', 'left', 'like', 'limit', 'match', 'natural', 'no', 'not', 'notnull', 'null', 'of', 'offset', 'on', 'or', 'order', 'outer', 'plan', 'pragma', 'primary', 'query', 'raise', 'references', 'reindex', 'release', 'rename', 'replace', 'restrict', 'right', 'rollback', 'row', 'savepoint', 'select', 'set', 'table', 'temp', 'temporary', 'then', 'to', 'transaction', 'trigger', 'union', 'unique', 'update', 'using', 'vacuum', 'values', 'view', 'virtual', 'when', 'where']); const NEDERLANDS = new Set(['naam', 'datum', 'bedrag', 'waarde', 'soort', 'aantal', 'bron', 'rekening', 'saldo', 'koers', 'prijs', 'weergave', 'gebruiker', 'instelling', 'actief', 'verwijderd', 'omschrijving', 'hoeveelheid']); it('geeft elke tabel een PascalCase-enkelvoud zonder underscore', () => { const fout = TABELLEN.filter(t => !/^[A-Z][A-Za-z0-9]*$/.test(t)); assert.deepEqual(fout, [], 'tabelnaam buiten het patroon ^[A-Z][A-Za-z0-9]*$'); }); it('gebruikt nergens een meervoud als tabelnaam', () => { const fout = TABELLEN.filter(t => { const laatste = woorden(t).at(-1); return MEERVOUD.test(laatste) && !MEERVOUD_UITZONDERING.has(laatste); }); assert.deepEqual(fout, [], 'de tabel heet naar de rij die hij bevat, dus enkelvoud'); }); it('geeft elke kolom een camelCase-naam zonder underscore', () => { const fout = []; for (const t of TABELLEN) { for (const k of KOLOMMEN.get(t)) { if (!/^[a-z][A-Za-z0-9]*$/.test(k.name)) fout.push(`${t}.${k.name}`); } } assert.deepEqual(fout, [], 'kolomnaam buiten het patroon ^[a-z][A-Za-z0-9]*$'); }); it('geeft elke zelfgemaakte index een Ix- of Ux-naam', () => { const fout = []; for (const t of TABELLEN) { for (const ix of INDEXEN.get(t)) { if (ix.origin !== 'c') continue; if (!/^(Ix|Ux)[A-Z][A-Za-z0-9]*$/.test(ix.name)) fout.push(`${t}: ${ix.name}`); if (ix.unique && !ix.name.startsWith('Ux')) fout.push(`${t}: ${ix.name} is uniek maar heet Ix`); if (!ix.unique && !ix.name.startsWith('Ix')) fout.push(`${t}: ${ix.name} is niet uniek maar heet Ux`); } } assert.deepEqual(fout, [], 'indexnaam buiten Ix/Ux + PascalCase'); }); it('geeft elke trigger een Tr-naam met zijn tabel erin', () => { const fout = db.prepare(`SELECT name, tbl_name FROM sqlite_master WHERE type = 'trigger'`).all() .filter(r => !new RegExp(`^Tr${r.tbl_name}[A-Z][A-Za-z0-9]*$`).test(r.name)) .map(r => `${r.tbl_name}: ${r.name}`); assert.deepEqual(fout, [], 'triggernaam buiten Tr + tabel + PascalCase'); }); it('gebruikt geen gereserveerd SQLite-woord als identifier', () => { const fout = []; for (const t of TABELLEN) { if (GERESERVEERD.has(t.toLowerCase())) fout.push(t); for (const k of KOLOMMEN.get(t)) { if (GERESERVEERD.has(k.name.toLowerCase())) fout.push(`${t}.${k.name}`); } } assert.deepEqual(fout, [], 'een gereserveerd woord zou quoting afdwingen, en niets wordt gequote'); }); it('gebruikt nergens een Nederlands woorddeel in een identifier', () => { const fout = []; for (const t of TABELLEN) { for (const w of woorden(t)) if (NEDERLANDS.has(w)) fout.push(`${t} (${w})`); for (const k of KOLOMMEN.get(t)) { for (const w of woorden(k.name)) if (NEDERLANDS.has(w)) fout.push(`${t}.${k.name} (${w})`); } } assert.deepEqual(fout, [], 'identifiers zijn Engels; de wáárden mogen Nederlands zijn'); }); it('herhaalt de naam van de tabel niet in een kolom', () => { const UITZONDERING = new Set([ 'WalletAddress.address', 'AiToolCall.tool', 'PriceHistory.fiatPrice', ]); const fout = []; for (const t of TABELLEN) { const fkKolommen = new Set(FKS.get(t).map(f => f.from)); const tabelWoorden = new Set(woorden(t)); for (const k of KOLOMMEN.get(t)) { if (fkKolommen.has(k.name) || UITZONDERING.has(`${t}.${k.name}`)) continue; const overlap = woorden(k.name).filter(w => tabelWoorden.has(w)); if (overlap.length) fout.push(`${t}.${k.name} (herhaalt '${overlap.join("','")}')`); } } assert.deepEqual(fout, [], 'de gekwalificeerde naam Tabel.kolom is de identifier; de tabelnaam hoort er niet nog eens in'); }); it('quotet geen enkele identifier in de DDL', () => { const zonderCommentaar = BASELINE_SCHEMA.replace(/--[^\n]*/g, '').replace(/'[^']*'/g, "''"); assert.equal(/"[A-Za-z_]/.test(zonderCommentaar), false, 'dubbele aanhalingstekens rond een identifier'); assert.equal(/\[[A-Za-z_]/.test(zonderCommentaar), false, 'blokhaken rond een identifier'); }); it('gebruikt geen DEFAULT in de DDL', () => { for (const t of TABELLEN) { for (const k of KOLOMMEN.get(t)) { assert.equal(k.dflt_value, null, `${t}.${k.name} heeft een DEFAULT`); } } }); }); describe('baseline: de kolomvolgorde', () => { it('zet id vooraan', () => { const fout = []; for (const t of TABELLEN) { const kolommen = KOLOMMEN.get(t); const idIndex = kolommen.findIndex(k => k.name === 'id'); if (idIndex > 0) fout.push(`${t} (id op positie ${idIndex})`); } assert.deepEqual(fout, [], 'id hoort de eerste kolom te zijn'); }); it('zet geen FK-kolom ná een waardekolom', () => { const LIJM = new Set(['Event.productCount']); const fout = []; for (const t of TABELLEN) { const kolommen = KOLOMMEN.get(t).map(k => k.name); const fkKolommen = new Set(FKS.get(t).map(f => f.from)); if (!fkKolommen.size) continue; const laatsteFk = kolommen.reduce((acc, naam, i) => (fkKolommen.has(naam) ? i : acc), -1); for (let i = 0; i < laatsteFk; i++) { const naam = kolommen[i]; if (fkKolommen.has(naam) || naam === 'id' || LIJM.has(`${t}.${naam}`)) continue; fout.push(`${t}: ${naam} staat vóór FK-kolom ${kolommen[laatsteFk]}`); } } assert.deepEqual(fout, [], 'verwijzingen horen als blok direct achter id (§6.7 groep 2)'); }); it('zet createdAt en updatedAt achteraan', () => { const fout = []; for (const t of TABELLEN) { const kolommen = KOLOMMEN.get(t).map(k => k.name); const heeftCreated = kolommen.includes('createdAt'); const heeftUpdated = kolommen.includes('updatedAt'); if (heeftCreated && heeftUpdated) { if (kolommen.at(-2) !== 'createdAt' || kolommen.at(-1) !== 'updatedAt') { fout.push(`${t}: verwachtte …, createdAt, updatedAt`); } } else if (heeftCreated && kolommen.at(-1) !== 'createdAt') { fout.push(`${t}: createdAt staat niet achteraan`); } else if (heeftUpdated && kolommen.at(-1) !== 'updatedAt') { fout.push(`${t}: updatedAt staat niet achteraan`); } } assert.deepEqual(fout, [], 'de boekhoud-tijdstempels staan altijd op het eind (§6.7 groep 7)'); }); }); describe('baseline: STRICT dwingt types af', () => { it('zet STRICT op elke tabel', () => { const zonder = TABELLEN.filter(t => { const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name = ?`).get(t).sql; return !/\)\s*STRICT\s*$/i.test(sql.trim()); }); assert.deepEqual(zonder, [], 'zonder STRICT accepteert SQLite tekst in een INTEGER-kolom'); }); it('weigert tekst in een INTEGER-kolom', () => { const eigen = openBaselineDb(); vulFixture(eigen); assert.throws( () => eigen.exec(`UPDATE Asset SET decimals = 'acht' WHERE id = 1`), /cannot store TEXT value in INTEGER column/, 'dit is precies wat een niet-STRICT tabel wél zou slikken'); }); it('houdt NOT NULL overeind', () => { const eigen = openBaselineDb(); vulFixture(eigen); assert.throws(() => eigen.exec(`UPDATE Asset SET symbol = NULL WHERE id = 1`), /NOT NULL/); }); }); describe('baseline: FK-handhaving', () => { it('staat aan op een verbinding die via de helper is geopend', () => { assert.equal(foreignKeysAan(openBaselineDb()), true); }); it('weigert een kindrij zonder ouder', () => { const eigen = openBaselineDb(); vulFixture(eigen); assert.throws(() => eigen.exec(`INSERT INTO MetalProduct (assetId, name, searchName, fineGrams, form) VALUES (999,'Wees','wees',1,'bar')`), /FOREIGN KEY constraint failed/); }); it('oefent élke FK één keer echt uit', () => { const MEE = { 'WalletSource.connectionId': `, kind = 'lnd', scriptType = NULL` }; const eigen = openBaselineDb(); vulFixture(eigen); let geteld = 0; for (const t of TABELLEN) { for (const fk of FKS.get(t)) { geteld++; let melding = ''; try { eigen.exec(`UPDATE ${t} SET ${fk.from} = 999999${MEE[`${t}.${fk.from}`] ?? ''} WHERE rowid = (SELECT MIN(rowid) FROM ${t})`); } catch (e) { melding = e.message; } assert.match(melding, /FOREIGN KEY constraint failed/, `${t}.${fk.from} → ${fk.table}(${fk.to}) gaf "${melding || 'geen fout'}". ` + `Een mismatch betekent dat de ouderzijde geen PK of UNIQUE is.`); } } assert.ok(geteld >= 35, `verwacht tientallen FK's, gevonden ${geteld}`); }); it('geeft elke FK-kolom een bruikbare index', () => { const fout = []; for (const t of TABELLEN) { const kolommen = KOLOMMEN.get(t); for (const fk of FKS.get(t)) { const isRowidPk = kolommen.some(k => k.name === fk.from && k.pk === 1 && k.type === 'INTEGER'); const gedekt = isRowidPk || INDEXEN.get(t).some(ix => !ix.partial && ix.kolommen[0] === fk.from); if (!gedekt) fout.push(`${t}.${fk.from}`); } } assert.deepEqual(fout, [], 'FK-kolom zonder index: elke ouder-delete wordt hier een volledige scan'); }); it('legt bij elke FK expliciet vast wat er bij verwijderen gebeurt', () => { const TOEGESTAAN = new Set(['CASCADE', 'RESTRICT', 'SET NULL', 'NO ACTION']); const fout = []; for (const t of TABELLEN) { for (const fk of FKS.get(t)) { if (!TOEGESTAAN.has(fk.on_delete)) fout.push(`${t}.${fk.from}: ${fk.on_delete}`); const inDdl = new RegExp(`REFERENCES ${fk.table}\\(${fk.to}\\) ON DELETE`, 'i'); if (!inDdl.test(BASELINE_SCHEMA)) fout.push(`${t}.${fk.from}: geen expliciete ON DELETE in de DDL`); } } assert.deepEqual(fout, []); }); it('ruimt bij CASCADE de kinddata op, en weigert bij NO ACTION', () => { const eigen = openBaselineDb(); vulFixture(eigen); assert.throws(() => eigen.exec(`DELETE FROM Account WHERE id = 1`), /FOREIGN KEY constraint failed/); eigen.exec(`DELETE FROM WalletTransactionEventLink`); eigen.exec(`DELETE FROM InterestCredit`); eigen.exec(`DELETE FROM ReconcileProposal`); eigen.exec(`DELETE FROM Event`); eigen.exec(`DELETE FROM Valuation`); eigen.exec(`DELETE FROM Account WHERE id = 1`); for (const t of ['WalletSource', 'WalletAddress', 'WalletTransaction', 'ScanLog', 'InterestSchedule']) { assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n, 0, `${t} had mee moeten cascaden`); } assert.equal(eigen.prepare(`SELECT accountId FROM ImportBatch`).get().accountId, null); }); it('weigert een provider te verwijderen die nog in gebruik is (RESTRICT)', () => { const eigen = openBaselineDb(); vulFixture(eigen); assert.throws(() => eigen.exec(`DELETE FROM Provider WHERE id = 1`), /FOREIGN KEY constraint failed/); }); it('laat een asset-cascade niet stuklopen op een NO ACTION eronder', () => { const eigen = openBaselineDb(); vulFixture(eigen); eigen.exec(`DELETE FROM WalletTransactionEventLink`); eigen.exec(`DELETE FROM InterestCredit`); eigen.exec(`DELETE FROM InterestSchedule`); eigen.exec(`UPDATE Event SET fromAccountId = NULL, toAccountId = NULL`); eigen.exec(`UPDATE Valuation SET accountId = NULL`); eigen.exec(`DELETE FROM PriceSource`); eigen.exec(`DELETE FROM PriceHistory`); eigen.exec(`DELETE FROM Asset WHERE id = 1`); assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM Event`).get().n, 0); assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM MetalProduct`).get().n, 0); }); }); describe('baseline: de voorwaardelijke CHECKs (§6.12 gebrek 4)', () => { let eigen; before(() => { eigen = openBaselineDb(); vulFixture(eigen); }); it('laat een variable-renteafspraak geen hoofdsom dragen', () => { assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET rateKind = 'variable' WHERE id = 1`), /CHECK/); }); it('laat een weekdag alleen bij een weekritme toe', () => { assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET creditCadence = 'monthly' WHERE id = 1`), /CHECK/); }); it('laat een maanddag alleen bij een maandritme toe', () => { assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET creditDayOfMonth = 3 WHERE id = 1`), /CHECK/); }); it('laat een einddatum niet vóór de startdatum liggen', () => { assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET endsOn = '2020-01-01' WHERE id = 1`), /CHECK/); }); it('laat rente op rente alleen toe op een vaste hoofdsom in het uitkeringsasset', () => { assert.doesNotThrow(() => eigen.exec(`UPDATE InterestSchedule SET compounding = 1 WHERE id = 1`)); assert.throws(() => eigen.exec(`UPDATE InterestSchedule SET principalAmount = NULL WHERE id = 1`), /CHECK/); eigen.exec(`UPDATE InterestSchedule SET compounding = 0 WHERE id = 1`); }); it('houdt het productpaar van een event bij elkaar', () => { assert.throws(() => eigen.exec(`UPDATE Event SET productCount = NULL WHERE id = 1`), /CHECK/); }); it('eist een prijs bij een handel', () => { assert.throws(() => eigen.exec(`UPDATE Event SET fiatPrice = NULL WHERE id = 1`), /CHECK/); }); it('laat geen negatief geld toe op de drie geldkolommen van een event', () => { for (const kolom of ['fiatPrice', 'fiatFee', 'unitFee']) { assert.throws(() => eigen.exec(`UPDATE Event SET ${kolom} = -1 WHERE id = 1`), /CHECK/, `${kolom} hoort niet negatief te kunnen`); } }); it('laat nul wél toe op die drie, want gratis verkregen is geen fout', () => { const voor = eigen.prepare(`SELECT fiatFee, unitFee FROM Event WHERE id = 1`).get(); assert.doesNotThrow(() => eigen.exec(`UPDATE Event SET fiatFee = 0, unitFee = 0 WHERE id = 1`)); eigen.prepare(`UPDATE Event SET fiatFee = ?, unitFee = ? WHERE id = 1`).run(voor.fiatFee, voor.unitFee); }); it('houdt de bovengrens van decimals in de DDL gelijk aan de config', () => { const sql = eigen.prepare(`SELECT sql FROM sqlite_master WHERE name='Asset'`).get().sql; assert.match(sql, new RegExp(`decimals <= ${CONFIG.MAX_ASSET_DECIMALS}\\b`)); }); it('laat een asset niet meer decimalen dragen dan de opslag kan teruglezen', () => { const voor = eigen.prepare(`SELECT decimals FROM Asset WHERE id = 1`).get().decimals; assert.throws(() => eigen.exec(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/); assert.doesNotThrow(() => eigen.exec(`UPDATE Asset SET decimals = 9 WHERE id = 1`)); eigen.prepare(`UPDATE Asset SET decimals = ? WHERE id = 1`).run(voor); }); it('laat een scriptType alleen bij een xpub-bron toe', () => { assert.throws(() => eigen.exec(`UPDATE WalletSource SET kind = 'address' WHERE id = 1`), /CHECK/); }); it('eist een verbinding bij een lnd-bron, staat hem toe op een on-chain bron en verbiedt hem bij nwc', () => { assert.doesNotThrow(() => eigen.exec(`UPDATE WalletSource SET connectionId = 1 WHERE id = 1`)); const tweede = openBaselineDb(); vulFixture(tweede); assert.throws(() => tweede.exec(`UPDATE WalletSource SET kind = 'lnd', scriptType = NULL WHERE id = 1`), /CHECK/); assert.throws(() => tweede.exec(`UPDATE WalletSource SET kind = 'nwc', scriptType = NULL, connectionId = 1 WHERE id = 1`), /CHECK/); }); it('laat een account niet tegelijk actief en afgesloten zijn', () => { assert.throws(() => eigen.exec(`UPDATE Account SET isActive = 0 WHERE id = 1`), /CHECK/, 'op non-actief zetten zonder closedAt hoort niet te kunnen'); assert.throws(() => eigen.exec(`UPDATE Account SET closedAt = '2026-01-01T00:00:00Z' WHERE id = 1`), /CHECK/, 'een afsluitdatum op een actief account evenmin'); }); it('weigert weergave in een ondereenheid die niet bestaat', () => { assert.throws(() => eigen.exec(`UPDATE Asset SET subunit = NULL WHERE id = 1`), /CHECK/); }); it('weigert een prijsrij zonder koers', () => { assert.throws(() => eigen.exec(`UPDATE PriceHistory SET fiatPrice = NULL WHERE id = 1`), /NOT NULL/); }); it('houdt blokhoogte en blokstijd bij elkaar', () => { assert.throws(() => eigen.exec(`UPDATE WalletTransaction SET blockTime = NULL WHERE id = 1`), /CHECK/); }); it('houdt de verbindingsuitslag en zijn moment bij elkaar', () => { assert.throws(() => eigen.exec(`UPDATE Connection SET lastStatus = 'ok' WHERE id = 1`), /CHECK/); }); it('laat maar één versierij toe', () => { assert.throws(() => eigen.exec(`INSERT INTO Schema (version) VALUES (61)`), /CHECK/); }); it('laat de rekenvaluta niet wijzigen of weghalen', () => { assert.throws(() => eigen.exec(`UPDATE Portfolio SET baseCurrency = 'USD'`), /fixed after creation/); assert.throws(() => eigen.exec(`UPDATE Portfolio SET createdAt = NULL`), /fixed after creation/); assert.throws(() => eigen.exec(`DELETE FROM Portfolio`), /fixed after creation/); assert.equal(eigen.prepare(`SELECT baseCurrency FROM Portfolio`).get().baseCurrency, 'EUR'); }); it('laat maar één portefeuillerij toe', () => { assert.throws(() => eigen.exec(`INSERT INTO Portfolio (id, baseCurrency) VALUES (2, 'USD')`), /CHECK/); }); it('eist een muntcode van drie hoofdletters', () => { for (const code of ['eur', 'EU', 'EURO', 'E1R', '']) { const leeg = openBaselineDb(); assert.throws(() => leeg.prepare(`INSERT INTO Portfolio (id, baseCurrency) VALUES (1, ?)`).run(code), /CHECK/, `'${code}' hoort geweigerd te worden`); } }); it('weigert een chain-index buiten 0 en 1', () => { assert.throws(() => eigen.exec(`UPDATE WalletAddress SET chainIndex = 2 WHERE id = 1`), /CHECK/); }); }); describe('baseline: unieke sleutels', () => { let eigen; before(() => { eigen = openBaselineDb(); vulFixture(eigen); }); it('Asset: symbol is uniek, en dat was het in het oude schema NIET', () => { assert.throws(() => eigen.exec(`INSERT INTO Asset (symbol, name, searchName, unit, displayUnit, decimals, kind) VALUES ('BTC','Bitcoin nogmaals','bitcoin nogmaals','BTC','unit',8,'crypto')`), /UNIQUE/); }); it('Label: de genormaliseerde naam is uniek, dus Werk en werk botsen', () => { assert.throws(() => eigen.exec(`INSERT INTO Label (name, searchName) VALUES ('WERK','werk')`), /UNIQUE/); }); it('MetalProduct: dezelfde naam mag bij een ánder asset', () => { eigen.exec(`INSERT INTO Asset (symbol, name, searchName, unit, displayUnit, decimals, kind) VALUES ('XAG','Zilver','zilver','ozt','unit',7,'metal')`); const zilver = eigen.prepare(`SELECT id FROM Asset WHERE symbol='XAG'`).get().id; eigen.exec(`INSERT INTO MetalProduct (assetId, name, searchName, fineGrams, form) VALUES (${zilver},'Baar 1 kg','baar 1 kg',10000000000,'bar')`); assert.throws(() => eigen.exec(`INSERT INTO MetalProduct (assetId, name, searchName, fineGrams, form) VALUES (${zilver},'BAAR 1 KG','baar 1 kg',1,'bar')`), /UNIQUE/); }); it('WalletTransaction: on-chain is (account, txid) uniek, en onderbouwt de idempotente scan-upsert', () => { assert.throws(() => eigen.exec(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt) VALUES (1,'onchain','aabbcc',1,'in','2026-01-01T00:00:00Z')`), /UNIQUE/); }); it('WalletTransaction: lightning is (bron, hash) uniek, los van on-chain en van een andere bron', () => { const ln = (bron, hash) => `INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, valueSats, blockTime, direction, createdAt) VALUES (1,${bron},'lightning','${hash}',21,'2026-01-01T00:00:00Z','in','2026-01-01T00:00:00Z')`; eigen.exec(ln(1, 'aabbcc')); assert.throws(() => eigen.exec(ln(1, 'aabbcc')), /UNIQUE/); eigen.exec(`INSERT INTO WalletSource (accountId, kind, value, createdAt) VALUES (1,'nwc','pubkey-twee','2026-01-01T00:00:00Z')`); const tweede = eigen.prepare(`SELECT MAX(id) AS id FROM WalletSource`).get().id; eigen.exec(ln(tweede, 'aabbcc')); }); it('WalletTransaction: een lightning-rij heeft een bron en een tijd, en geen blokhoogte', () => { const rij = (kolommen, waarden) => () => eigen.exec(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt${kolommen}) VALUES (1,'lightning','ff00',1,'in','2026-01-01T00:00:00Z'${waarden})`); assert.throws(rij(', sourceId', ', 1'), /CHECK/, 'zonder tijd'); assert.throws(rij(', blockTime', `, '2026-01-01T00:00:00Z'`), /CHECK/, 'zonder bron'); assert.throws(rij(', sourceId, blockTime, blockHeight', `, 1, '2026-01-01T00:00:00Z', 900000`), /CHECK/, 'met een blokhoogte'); assert.throws(() => eigen.exec(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt) VALUES (1,'bliksem','ff01',1,'in','2026-01-01T00:00:00Z')`), /CHECK/, 'onbekende laag'); }); it('PriceHistory: (asset, provider, moment, resolutie) is uniek', () => { assert.throws(() => eigen.exec(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (1,1,1.0,'day','2026-08-12T10:00:00.000Z')`), /UNIQUE/); }); it('Connection: hooguit één actief btc_explorer-profiel', () => { assert.throws(() => eigen.exec(`INSERT INTO Connection (name, searchName, url, kind, isActive, isSystem, createdAt, updatedAt) VALUES ('Tweede','tweede','https://x.test','btc_explorer',1,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`), /UNIQUE/); }); it('DashboardProfile: hooguit één actief profiel', () => { assert.throws(() => eigen.exec(`INSERT INTO DashboardProfile (name, searchName, sortOrder, isActive, isSystem, createdAt, updatedAt) VALUES ('Fiscaal','fiscaal',1,1,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`), /UNIQUE/); }); it('DashboardProfile: een tweede INACTIEF profiel mag wel', () => { eigen.exec(`INSERT INTO DashboardProfile (name, searchName, sortOrder, isActive, isSystem, createdAt, updatedAt) VALUES ('Fiscaal','fiscaal',1,0,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`); assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM DashboardProfile`).get().n, 2); }); it('ProviderCoverage: één rij per (provider, symbool)', () => { assert.throws(() => eigen.exec(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (1,'BTC')`), /UNIQUE/); }); it('ProviderCoverage: een symbool dat géén asset is, mag gewoon', () => { eigen.exec(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (1,'DOGE')`); assert.equal(eigen.prepare(`SELECT COUNT(*) AS n FROM ProviderCoverage WHERE symbol='DOGE'`).get().n, 1); }); }); describe('baseline: geen enkele CHECK-lijst is een losse literal', () => { const GEKOPPELD = [ ['Asset', 'kind', ENUMS.ASSET_KIND], ['Asset', 'displayUnit', ENUMS.ASSET_DISPLAY_UNIT], ['Account', 'kind', ENUMS.ACCOUNT_KIND], ['MetalProduct', 'form', ENUMS.METAL_FORM], ['Event', 'type', ENUMS.EVENT_TYPE], ['Event', 'source', ENUMS.EVENT_SOURCE], ['Valuation', 'source', ENUMS.VALUATION_SOURCE], ['ScanLog', 'status', ENUMS.SCAN_STATUS], ['WalletTransaction', 'direction', ENUMS.RECONCILE_DIRECTION], ['WalletSource', 'kind', ENUMS.WALLET_SOURCE_KIND], ['WalletSource', 'scriptType', ENUMS.SCRIPT_TYPE], ['ReconcileProposal', 'direction', ENUMS.RECONCILE_DIRECTION], ['ReconcileProposal', 'suggestedType', ENUMS.RECONCILE_SUGGESTED], ['ReconcileProposal', 'status', ENUMS.RECONCILE_STATUS], ['WalletTransactionEventLink', 'method', ENUMS.LINK_METHOD], ['InterestSchedule', 'rateKind', ENUMS.INTEREST_RATE_KIND], ['InterestSchedule', 'creditCadence', ENUMS.INTEREST_CADENCE], ['InterestCredit', 'status', ENUMS.INTEREST_CREDIT_STATUS], ['Notification', 'kind', ENUMS.NOTIFICATION_KIND], ['Connection', 'kind', ENUMS.CONNECTION_KIND], ['Connection', 'lastStatus', ENUMS.CONNECTION_STATUS], ['PriceHistory', 'resolution', ENUMS.PRICE_RESOLUTION], ['PriceSource', 'status', ENUMS.PRICE_SOURCE_STATUS], ['PriceSource', 'capability', SOURCE_CAPABILITIES], ['ProviderEndpoint', 'capability', CAPABILITIES], ['ProviderEndpoint', 'method', HTTP_METHODS], ['ProviderOutputMap', 'capability', CAPABILITIES], ['ProviderOutputMap', 'internalField', OUTPUT_FIELDS], ['ProviderOutputMap', 'transform', TRANSFORMS], ['ProviderCoverage', 'symbol', null], ['ApiCallMetric', 'capability', CAPABILITIES], ['ProviderAuthenticationHmac', 'hashAlgorithm', ENUMS.HMAC_HASH_ALGORITHM], ['ProviderAuthenticationHmac', 'encoding', ENUMS.HMAC_ENCODING], ['Provider', 'authenticationKind', Object.keys(AUTH_KINDS)], ['AiToolCall', 'outcome', ENUMS.AI_CALL_OUTCOME], ].filter(([, , waarden]) => waarden !== null); function checkWaarden(tabel, kolom) { const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name = ?`).get(tabel).sql; const m = sql.match(new RegExp(`CHECK ?\\(\\s*(?:${kolom} IS NULL OR )?${kolom} IN \\(([^)]*)\\)`)); assert.ok(m, `geen CHECK-lijst gevonden voor ${tabel}.${kolom}: wordt die kolom nog bewaakt?`); return m[1].split(',').map(s => s.trim().replace(/^'|'$/g, '')); } for (const [tabel, kolom, waarden] of GEKOPPELD) { it(`${tabel}.${kolom} dekt exact de registry`, () => { assert.deepEqual([...checkWaarden(tabel, kolom)].sort(), [...waarden].sort(), 'de DDL en de registry lopen uit de pas; onthoud dat een CHECK-wijziging een tabel-rebuild kost'); }); } it('bewaakt elke gekoppelde kolom precies één keer', () => { const sleutels = GEKOPPELD.map(([t, k]) => `${t}.${k}`); assert.equal(new Set(sleutels).size, sleutels.length, 'dubbele regel in de GEKOPPELD-tabel'); }); it('laat DashboardWidget.type bewust zónder CHECK', () => { const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='DashboardWidget'`).get().sql; assert.equal(/CHECK \(type IN/.test(sql), false); }); }); describe('baseline: de bewuste uitzonderingen', () => { it('houdt precies vijf tabellen zonder surrogaat-id', () => { const zonderId = TABELLEN.filter(t => !KOLOMMEN.get(t).some(k => k.name === 'id')); assert.deepEqual(zonderId.sort(), ['ApiCallMetric', 'Preference', 'ProviderAuthenticationHmac', 'ProviderCallLimit', 'ProviderFixedRate']); }); it('gebruikt nergens AUTOINCREMENT', () => { assert.equal(/AUTOINCREMENT/i.test(BASELINE_SCHEMA), false); }); it('geeft ProviderCoverage geen FK naar Asset', () => { const fks = db.prepare(`PRAGMA foreign_key_list(ProviderCoverage)`).all(); assert.deepEqual(fks.map(f => f.table), ['Provider'], 'dekking mag een symbool noemen dat hier geen asset is; een FK zou die dekking weggooien'); }); });