Files
AtreidesAssetsFree/tests/db/baseline.test.mjs
T

627 lines
30 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// 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');
});
});