Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+627
View File
@@ -0,0 +1,627 @@
// 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');
});
});
+78
View File
@@ -0,0 +1,78 @@
// Generated, do not edit. sha256:7e68bde59de6fb368899c1d7c4f191cb1a698e2a3f71f077372987a488b10794
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { openBaselineDb } from '../helpers/baseline-db.mjs';
import { CONFIG, ENUMS } from '../../shared/config.js';
let db;
const NU = '2026-09-07T12:00:00Z';
const schrijf = (over = {}) => {
const r = { tableName: 'Account', rowId: 1, operation: 'update', origin: 'manual',
originId: null, changes: '{"name":["a","b"]}', occurredAt: NU, ...over };
db.prepare(`INSERT INTO ChangeLog (tableName, rowId, operation, origin, originId, changes, occurredAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(r.tableName, r.rowId, r.operation, r.origin, r.originId, r.changes, r.occurredAt);
};
before(() => { db = openBaselineDb(); });
after(() => db.close());
describe('ChangeLog: wat de tabel accepteert', () => {
it('neemt een gewone wijziging aan', () => {
schrijf({ rowId: 101 });
const r = db.prepare(`SELECT * FROM ChangeLog WHERE rowId = 101`).get();
assert.equal(r.operation, 'update');
assert.equal(r.originId, null, 'een handmatige wijziging heeft geen rij die hem aanwijst');
});
it('neemt een verwijdering aan, met de hele rij in changes', () => {
schrijf({ rowId: 102, operation: 'delete', changes: '{"id":102,"name":"Kluis","kind":"vault"}' });
const r = db.prepare(`SELECT operation, changes FROM ChangeLog WHERE rowId = 102`).get();
assert.equal(r.operation, 'delete');
assert.deepEqual(JSON.parse(r.changes).name, 'Kluis');
});
it('neemt elke herkomst uit de enum aan', () => {
for (const [i, origin] of ENUMS.CHANGE_ORIGIN.entries()) {
schrijf({ rowId: 200 + i, origin });
}
const n = db.prepare(`SELECT COUNT(*) AS n FROM ChangeLog WHERE rowId >= 200 AND rowId < 300`).get().n;
assert.equal(n, ENUMS.CHANGE_ORIGIN.length);
});
it('weigert een insert-regel, want die bestaat niet', () => {
assert.throws(() => schrijf({ rowId: 301, operation: 'insert' }), /CHECK/i,
'een nieuw record heeft zijn eigen createdAt; dat levert geen logboekregel op');
});
it('weigert een onbekende herkomst', () => {
assert.throws(() => schrijf({ rowId: 302, origin: 'zomaar' }), /CHECK/i);
});
it('weigert een regel zonder tabel, rij, herkomst of moment', () => {
assert.throws(() => schrijf({ rowId: 303, tableName: null }), /NOT NULL/i);
assert.throws(() => schrijf({ rowId: null }), /NOT NULL/i);
assert.throws(() => schrijf({ rowId: 304, origin: null }), /NOT NULL/i);
assert.throws(() => schrijf({ rowId: 305, occurredAt: null }), /NOT NULL/i);
assert.throws(() => schrijf({ rowId: 306, changes: null }), /NOT NULL/i);
});
});
describe('ChangeLog: hoe hij in de registers staat', () => {
it('staat als append-only logboek buiten de verwijderbare tabellen', () => {
assert.ok(CONFIG.DB_TABLES.includes('ChangeLog'));
assert.ok(!CONFIG.DB_DELETABLE_TABLES.includes('ChangeLog'));
});
it('doet zelf niet mee in het logboek', () => {
assert.equal(CONFIG.DB_HISTORY.UNTRACKED.ChangeLog, 'logboek');
assert.ok(!CONFIG.DB_HISTORY.TRACKED.includes('ChangeLog'), 'een logboek van een logboek zegt niets');
});
it('deelt `changes` niet met de AI', async () => {
const { FIELD_POLICY } = await import('../../server/ai/policy.js');
assert.equal(FIELD_POLICY.changes, 'drop');
assert.equal(FIELD_POLICY.rowId, 'handle', 'een verwijzing, geen getal');
});
});
File diff suppressed because it is too large. Load diff
+568
View File
@@ -0,0 +1,568 @@
// Generated, do not edit. sha256:95b828575da8a035648fa6c5b1be9927259b17fc10e9fccfc392b7da47d8f94f
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import { openBaselineDb } from '../helpers/baseline-db.mjs';
import { schemaVorm } from '../helpers/schema-vorm.mjs';
import { CONFIG } from '../../shared/config.js';
let REGELS;
before(() => {
const db = openBaselineDb();
REGELS = schemaVorm(db);
db.close();
});
describe('schema-snapshot', () => {
it('heeft de verwachte omvang', () => {
const nieuw = REGELS.filter(r => !VERWACHT.includes(r));
const weg = VERWACHT.filter(r => !REGELS.includes(r));
assert.deepEqual({ nieuw, weg }, { nieuw: [], weg: [] },
'de vorm van het schema is gewijzigd; genereer de snapshot opnieuw met scripts/schema-snapshot.mjs');
});
it('staat regel voor regel gelijk aan de vastgelegde vorm', () => {
assert.deepEqual(REGELS, VERWACHT);
});
it('hoort bij deze schemaversie', () => {
assert.equal(CONFIG.SCHEMA_VERSION, 128,'nieuwe schemaversie? dan hoort er een migratie bij (§8)');
});
});
const VERWACHT = [
"Account.closedAt: TEXT",
"Account.createdAt: TEXT",
"Account.description: TEXT",
"Account.id: INTEGER PK1",
"Account.isActive: INTEGER NOT NULL",
"Account.isOwnCustody: INTEGER NOT NULL",
"Account.kind: TEXT NOT NULL",
"Account.name: TEXT NOT NULL",
"Account.scanEnabled: INTEGER NOT NULL",
"Account.searchName: TEXT NOT NULL",
"Account.taxCategory: TEXT",
"Account.taxScope: TEXT",
"Account.updatedAt: TEXT",
"Account.wealthScope: TEXT",
"AiProposal.accountId -> Account.id ON DELETE CASCADE",
"AiProposal.accountId: INTEGER",
"AiProposal.accountKind: TEXT",
"AiProposal.amount: INTEGER",
"AiProposal.assetId -> Asset.id ON DELETE CASCADE",
"AiProposal.assetId: INTEGER",
"AiProposal.balance: INTEGER",
"AiProposal.createdAccountId -> Account.id ON DELETE SET NULL",
"AiProposal.createdAccountId: INTEGER",
"AiProposal.createdAt: TEXT NOT NULL",
"AiProposal.createdEventId -> Event.id ON DELETE SET NULL",
"AiProposal.createdEventId: INTEGER",
"AiProposal.createdValuationId -> Valuation.id ON DELETE SET NULL",
"AiProposal.createdValuationId: INTEGER",
"AiProposal.currency: TEXT",
"AiProposal.detail: TEXT",
"AiProposal.eventType: TEXT",
"AiProposal.fiatFee: REAL",
"AiProposal.fiatPrice: REAL",
"AiProposal.fromAccountId -> Account.id ON DELETE CASCADE",
"AiProposal.fromAccountId: INTEGER",
"AiProposal.id: INTEGER PK1",
"AiProposal.isOwnCustody: INTEGER",
"AiProposal.key: TEXT",
"AiProposal.kind: TEXT NOT NULL",
"AiProposal.name: TEXT",
"AiProposal.note: TEXT",
"AiProposal.observedAt: TEXT",
"AiProposal.resolvedAt: TEXT",
"AiProposal.scope: TEXT",
"AiProposal.status: TEXT NOT NULL",
"AiProposal.toAccountId -> Account.id ON DELETE CASCADE",
"AiProposal.toAccountId: INTEGER",
"AiProposal.unitFee: INTEGER",
"AiProposal.value: TEXT",
"AiToolCall.arguments: TEXT",
"AiToolCall.charactersOut: INTEGER",
"AiToolCall.client: TEXT",
"AiToolCall.detail: TEXT",
"AiToolCall.fieldsShared: TEXT",
"AiToolCall.id: INTEGER PK1",
"AiToolCall.occurredAt: TEXT NOT NULL",
"AiToolCall.outcome: TEXT NOT NULL",
"AiToolCall.serverVersion: TEXT",
"AiToolCall.sessionId: TEXT NOT NULL",
"AiToolCall.tool: TEXT NOT NULL",
"AiToolCallMessage.callId -> AiToolCall.id ON DELETE CASCADE",
"AiToolCallMessage.callId: INTEGER NOT NULL",
"AiToolCallMessage.id: INTEGER PK1",
"AiToolCallMessage.kind: TEXT NOT NULL",
"AiToolCallMessage.occurredAt: TEXT NOT NULL",
"AiToolCallMessage.text: TEXT NOT NULL",
"ApiCallMetric.bucketMinute: TEXT NOT NULL PK3",
"ApiCallMetric.capability: TEXT NOT NULL PK2",
"ApiCallMetric.failCount: INTEGER NOT NULL",
"ApiCallMetric.maxLatencyMs: INTEGER NOT NULL",
"ApiCallMetric.okCount: INTEGER NOT NULL",
"ApiCallMetric.providerId -> Provider.id ON DELETE CASCADE",
"ApiCallMetric.providerId: INTEGER NOT NULL PK1",
"ApiCallMetric.sumLatencyMs: INTEGER NOT NULL",
"Asset.balanceCapture: INTEGER",
"Asset.createdAt: TEXT",
"Asset.decimals: INTEGER NOT NULL",
"Asset.description: TEXT",
"Asset.displayUnit: TEXT NOT NULL",
"Asset.id: INTEGER PK1",
"Asset.kind: TEXT NOT NULL",
"Asset.name: TEXT NOT NULL",
"Asset.searchName: TEXT NOT NULL",
"Asset.subunit: TEXT",
"Asset.symbol: TEXT NOT NULL",
"Asset.taxCategory: TEXT",
"Asset.taxScope: TEXT",
"Asset.unit: TEXT NOT NULL",
"Asset.updatedAt: TEXT",
"AssetNote.assetId -> Asset.id ON DELETE CASCADE",
"AssetNote.assetId: INTEGER NOT NULL",
"AssetNote.body: TEXT NOT NULL",
"AssetNote.createdAt: TEXT NOT NULL",
"AssetNote.id: INTEGER PK1",
"BusSignal.id: INTEGER PK1",
"BusSignal.name: TEXT NOT NULL",
"BusSignal.occurredAt: TEXT NOT NULL",
"ChangeLog.changes: TEXT NOT NULL",
"ChangeLog.id: INTEGER PK1",
"ChangeLog.occurredAt: TEXT NOT NULL",
"ChangeLog.operation: TEXT NOT NULL",
"ChangeLog.origin: TEXT NOT NULL",
"ChangeLog.originId: INTEGER",
"ChangeLog.rowId: INTEGER NOT NULL",
"ChangeLog.tableName: TEXT NOT NULL",
"Command.claimedAt: TEXT",
"Command.claimedBy: TEXT",
"Command.createdAt: TEXT NOT NULL",
"Command.detail: TEXT",
"Command.expiresAt: TEXT",
"Command.id: INTEGER PK1",
"Command.kind: TEXT NOT NULL",
"Command.payload: TEXT",
"Command.resolvedAt: TEXT",
"Command.state: TEXT NOT NULL",
"Connection.createdAt: TEXT NOT NULL",
"Connection.description: TEXT",
"Connection.id: INTEGER PK1",
"Connection.isActive: INTEGER NOT NULL",
"Connection.isSystem: INTEGER NOT NULL",
"Connection.kind: TEXT NOT NULL",
"Connection.lastCheckedAt: TEXT",
"Connection.lastStatus: TEXT",
"Connection.name: TEXT NOT NULL",
"Connection.searchName: TEXT NOT NULL",
"Connection.updatedAt: TEXT NOT NULL",
"Connection.url: TEXT NOT NULL",
"DashboardProfile.createdAt: TEXT NOT NULL",
"DashboardProfile.description: TEXT",
"DashboardProfile.id: INTEGER PK1",
"DashboardProfile.isActive: INTEGER NOT NULL",
"DashboardProfile.isSystem: INTEGER NOT NULL",
"DashboardProfile.name: TEXT NOT NULL",
"DashboardProfile.searchName: TEXT NOT NULL",
"DashboardProfile.sortOrder: INTEGER NOT NULL",
"DashboardProfile.updatedAt: TEXT NOT NULL",
"DashboardWidget.assetId -> Asset.id ON DELETE CASCADE",
"DashboardWidget.assetId: INTEGER",
"DashboardWidget.createdAt: TEXT",
"DashboardWidget.gridX: INTEGER NOT NULL",
"DashboardWidget.gridY: INTEGER NOT NULL",
"DashboardWidget.id: INTEGER PK1",
"DashboardWidget.instanceKey: TEXT NOT NULL",
"DashboardWidget.profileId -> DashboardProfile.id ON DELETE CASCADE",
"DashboardWidget.profileId: INTEGER NOT NULL",
"DashboardWidget.settings: TEXT",
"DashboardWidget.spanHeight: INTEGER",
"DashboardWidget.spanWidth: INTEGER NOT NULL",
"DashboardWidget.type: TEXT NOT NULL",
"Event.amount: INTEGER NOT NULL",
"Event.assetId -> Asset.id ON DELETE CASCADE",
"Event.assetId: INTEGER NOT NULL",
"Event.createdAt: TEXT NOT NULL",
"Event.externalAddress: TEXT",
"Event.fiatFee: REAL",
"Event.fiatPrice: REAL",
"Event.fromAccountId -> Account.id ON DELETE NO ACTION",
"Event.fromAccountId: INTEGER",
"Event.id: INTEGER PK1",
"Event.importReference: TEXT",
"Event.note: TEXT",
"Event.occurredAt: TEXT NOT NULL",
"Event.priceOrigin: TEXT",
"Event.productCount: INTEGER",
"Event.productId -> MetalProduct.id ON DELETE NO ACTION",
"Event.productId: INTEGER",
"Event.source: TEXT NOT NULL",
"Event.toAccountId -> Account.id ON DELETE NO ACTION",
"Event.toAccountId: INTEGER",
"Event.type: TEXT NOT NULL",
"Event.unitFee: INTEGER",
"Event.updatedAt: TEXT",
"HygieneAcceptance.createdAt: TEXT NOT NULL",
"HygieneAcceptance.fingerprint: TEXT NOT NULL",
"HygieneAcceptance.id: INTEGER PK1",
"HygieneAcceptance.kind: TEXT NOT NULL",
"HygieneAcceptance.note: TEXT",
"HygieneAcceptance.summary: TEXT NOT NULL",
"INDEX IxAccountSearchName ON Account(searchName)",
"INDEX IxAiProposalAccountId ON AiProposal(accountId)",
"INDEX IxAiProposalAssetId ON AiProposal(assetId)",
"INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)",
"INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)",
"INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)",
"INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)",
"INDEX IxAiProposalStatus ON AiProposal(status)",
"INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)",
"INDEX IxAiToolCallMessageCallId ON AiToolCallMessage(callId)",
"INDEX IxAiToolCallOccurredAt ON AiToolCall(occurredAt)",
"INDEX IxAssetSearchName ON Asset(searchName)",
"INDEX IxChangeLogOccurredAt ON ChangeLog(occurredAt)",
"INDEX IxChangeLogTableNameRowId ON ChangeLog(tableName,rowId)",
"INDEX IxDashboardWidgetAssetId ON DashboardWidget(assetId)",
"INDEX IxEventAssetId ON Event(assetId)",
"INDEX IxEventExternalAddress ON Event(externalAddress)",
"INDEX IxEventFromAccountId ON Event(fromAccountId)",
"INDEX IxEventImportReference ON Event(importReference)",
"INDEX IxEventOccurredAt ON Event(occurredAt)",
"INDEX IxEventProductId ON Event(productId)",
"INDEX IxEventToAccountId ON Event(toAccountId)",
"INDEX IxEventType ON Event(type)",
"INDEX IxHygieneAcceptanceKind ON HygieneAcceptance(kind)",
"INDEX IxIgnoredPriceGapAssetId ON IgnoredPriceGap(assetId)",
"INDEX IxImportBatchAccountId ON ImportBatch(accountId)",
"INDEX IxIntegrationCallOccurredAt ON IntegrationCall(occurredAt)",
"INDEX IxInterestCreditEventId ON InterestCredit(eventId)",
"INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)",
"INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)",
"INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)",
"INDEX IxNotificationKind ON Notification(kind)",
"INDEX IxPriceAbsenceProviderId ON PriceAbsence(providerId)",
"INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId,observedAt)",
"INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)",
"INDEX IxPriceSourceProviderId ON PriceSource(providerId)",
"INDEX IxProviderCoverageSymbol ON ProviderCoverage(symbol)",
"INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId,capability)",
"INDEX IxProviderSearchName ON Provider(searchName)",
"INDEX IxReconcileProposalAccountId ON ReconcileProposal(accountId)",
"INDEX IxReconcileProposalAssetId ON ReconcileProposal(assetId)",
"INDEX IxReconcileProposalResolvedEventId ON ReconcileProposal(resolvedEventId)",
"INDEX IxReconcileProposalStatus ON ReconcileProposal(status)",
"INDEX IxReconcileProposalWalletTransactionId ON ReconcileProposal(walletTransactionId)",
"INDEX IxScanLogAccountIdScannedAt ON ScanLog(accountId,scannedAt)",
"INDEX IxValuationAccountId ON Valuation(accountId)",
"INDEX IxWalletAddressAccountId ON WalletAddress(accountId)",
"INDEX IxWalletAddressSourceId ON WalletAddress(sourceId)",
"INDEX IxWalletSourceAccountId ON WalletSource(accountId)",
"INDEX IxWalletSourceConnectionId ON WalletSource(connectionId)",
"INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)",
"INDEX IxWalletTransactionEventLinkEventId ON WalletTransactionEventLink(eventId)",
"INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)",
"INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)",
"IgnoredPriceGap.assetId -> Asset.id ON DELETE CASCADE",
"IgnoredPriceGap.assetId: INTEGER NOT NULL",
"IgnoredPriceGap.createdAt: TEXT NOT NULL",
"IgnoredPriceGap.fromDay: TEXT NOT NULL",
"IgnoredPriceGap.id: INTEGER PK1",
"IgnoredPriceGap.untilDay: TEXT NOT NULL",
"ImportBatch.accountId -> Account.id ON DELETE SET NULL",
"ImportBatch.accountId: INTEGER",
"ImportBatch.filename: TEXT NOT NULL",
"ImportBatch.id: INTEGER PK1",
"ImportBatch.importedAt: TEXT NOT NULL",
"ImportBatch.origin: TEXT",
"ImportBatch.rawData: TEXT",
"ImportBatch.rowCount: INTEGER NOT NULL",
"IntegrationCall.detail: TEXT",
"IntegrationCall.errorKey: TEXT",
"IntegrationCall.httpStatus: INTEGER",
"IntegrationCall.id: INTEGER PK1",
"IntegrationCall.latencyMs: INTEGER",
"IntegrationCall.occurredAt: TEXT NOT NULL",
"IntegrationCall.operation: TEXT NOT NULL",
"IntegrationCall.outcome: TEXT NOT NULL",
"IntegrationCall.providerKey: TEXT",
"IntegrationCall.requestId: TEXT",
"IntegrationCall.subject: TEXT",
"IntegrationCall.target: TEXT NOT NULL",
"InterestCredit.createdAt: TEXT NOT NULL",
"InterestCredit.eventId -> Event.id ON DELETE CASCADE",
"InterestCredit.eventId: INTEGER",
"InterestCredit.id: INTEGER PK1",
"InterestCredit.periodEnd: TEXT NOT NULL",
"InterestCredit.periodStart: TEXT NOT NULL",
"InterestCredit.scheduleId -> InterestSchedule.id ON DELETE CASCADE",
"InterestCredit.scheduleId: INTEGER NOT NULL",
"InterestCredit.status: TEXT NOT NULL",
"InterestCredit.updatedAt: TEXT",
"InterestPeriod.annualRate: REAL NOT NULL",
"InterestPeriod.createdAt: TEXT",
"InterestPeriod.id: INTEGER PK1",
"InterestPeriod.scheduleId -> InterestSchedule.id ON DELETE CASCADE",
"InterestPeriod.scheduleId: INTEGER NOT NULL",
"InterestPeriod.startsOn: TEXT NOT NULL",
"InterestPeriod.updatedAt: TEXT",
"InterestSchedule.accountId -> Account.id ON DELETE CASCADE",
"InterestSchedule.accountId: INTEGER NOT NULL",
"InterestSchedule.annualRate: REAL",
"InterestSchedule.compounding: INTEGER NOT NULL",
"InterestSchedule.createdAt: TEXT NOT NULL",
"InterestSchedule.creditCadence: TEXT",
"InterestSchedule.creditDayOfMonth: INTEGER",
"InterestSchedule.creditWeekday: INTEGER",
"InterestSchedule.endsOn: TEXT",
"InterestSchedule.id: INTEGER PK1",
"InterestSchedule.note: TEXT",
"InterestSchedule.payoutAssetId -> Asset.id ON DELETE CASCADE",
"InterestSchedule.payoutAssetId: INTEGER NOT NULL",
"InterestSchedule.principalAmount: INTEGER",
"InterestSchedule.principalAssetId -> Asset.id ON DELETE NO ACTION",
"InterestSchedule.principalAssetId: INTEGER",
"InterestSchedule.rateKind: TEXT NOT NULL",
"InterestSchedule.startsOn: TEXT NOT NULL",
"InterestSchedule.updatedAt: TEXT",
"Label.createdAt: TEXT",
"Label.description: TEXT",
"Label.id: INTEGER PK1",
"Label.name: TEXT NOT NULL",
"Label.searchName: TEXT NOT NULL",
"Label.updatedAt: TEXT",
"MetalProduct.assetId -> Asset.id ON DELETE CASCADE",
"MetalProduct.assetId: INTEGER NOT NULL",
"MetalProduct.createdAt: TEXT",
"MetalProduct.description: TEXT",
"MetalProduct.fineGrams: INTEGER NOT NULL",
"MetalProduct.form: TEXT NOT NULL",
"MetalProduct.id: INTEGER PK1",
"MetalProduct.name: TEXT NOT NULL",
"MetalProduct.searchName: TEXT NOT NULL",
"MetalProduct.updatedAt: TEXT",
"Notification.count: INTEGER NOT NULL",
"Notification.createdAt: TEXT NOT NULL",
"Notification.dismissedAt: TEXT",
"Notification.highWater: INTEGER NOT NULL",
"Notification.id: INTEGER PK1",
"Notification.kind: TEXT NOT NULL",
"Portfolio.baseCurrency: TEXT NOT NULL",
"Portfolio.createdAt: TEXT",
"Portfolio.id: INTEGER PK1",
"Preference.createdAt: TEXT",
"Preference.key: TEXT NOT NULL PK2",
"Preference.scope: TEXT NOT NULL PK1",
"Preference.value: TEXT NOT NULL",
"PriceAbsence.assetId -> Asset.id ON DELETE CASCADE",
"PriceAbsence.assetId: INTEGER NOT NULL",
"PriceAbsence.createdAt: TEXT NOT NULL",
"PriceAbsence.day: TEXT NOT NULL",
"PriceAbsence.id: INTEGER PK1",
"PriceAbsence.providerId -> Provider.id ON DELETE CASCADE",
"PriceAbsence.providerId: INTEGER NOT NULL",
"PriceHistory.assetId -> Asset.id ON DELETE CASCADE",
"PriceHistory.assetId: INTEGER NOT NULL",
"PriceHistory.createdAt: TEXT",
"PriceHistory.fiatPrice: REAL NOT NULL",
"PriceHistory.id: INTEGER PK1",
"PriceHistory.observedAt: TEXT NOT NULL",
"PriceHistory.providerId -> Provider.id ON DELETE RESTRICT",
"PriceHistory.providerId: INTEGER NOT NULL",
"PriceHistory.resolution: TEXT NOT NULL",
"PriceHistory.sourceName: TEXT",
"PriceHistory.updatedAt: TEXT",
"PriceSource.assetId -> Asset.id ON DELETE CASCADE",
"PriceSource.assetId: INTEGER NOT NULL",
"PriceSource.capability: TEXT NOT NULL",
"PriceSource.createdAt: TEXT",
"PriceSource.id: INTEGER PK1",
"PriceSource.providerId -> Provider.id ON DELETE RESTRICT",
"PriceSource.providerId: INTEGER NOT NULL",
"PriceSource.rank: INTEGER",
"PriceSource.status: TEXT NOT NULL",
"Provider.authenticationHeaderName: TEXT",
"Provider.authenticationKind: TEXT NOT NULL",
"Provider.authenticationParameterName: TEXT",
"Provider.authenticationRequired: INTEGER NOT NULL",
"Provider.baseUrl: TEXT NOT NULL",
"Provider.createdAt: TEXT NOT NULL",
"Provider.description: TEXT",
"Provider.id: INTEGER PK1",
"Provider.isActive: INTEGER NOT NULL",
"Provider.isSeed: INTEGER NOT NULL",
"Provider.isSystem: INTEGER NOT NULL",
"Provider.key: TEXT NOT NULL",
"Provider.name: TEXT NOT NULL",
"Provider.searchName: TEXT NOT NULL",
"ProviderAuthenticationHmac.createdAt: TEXT",
"ProviderAuthenticationHmac.encoding: TEXT NOT NULL",
"ProviderAuthenticationHmac.hashAlgorithm: TEXT NOT NULL",
"ProviderAuthenticationHmac.keyHeader: TEXT NOT NULL",
"ProviderAuthenticationHmac.messageParts: TEXT NOT NULL",
"ProviderAuthenticationHmac.providerId -> Provider.id ON DELETE CASCADE",
"ProviderAuthenticationHmac.providerId: INTEGER PK1",
"ProviderAuthenticationHmac.signatureHeader: TEXT NOT NULL",
"ProviderAuthenticationHmac.timestampHeader: TEXT NOT NULL",
"ProviderAuthenticationHmac.windowHeader: TEXT",
"ProviderAuthenticationHmac.windowMs: INTEGER",
"ProviderCallLimit.callsPerMonth: INTEGER NOT NULL",
"ProviderCallLimit.createdAt: TEXT",
"ProviderCallLimit.providerId -> Provider.id ON DELETE CASCADE",
"ProviderCallLimit.providerId: INTEGER PK1",
"ProviderCoverage.createdAt: TEXT",
"ProviderCoverage.id: INTEGER PK1",
"ProviderCoverage.providerId -> Provider.id ON DELETE CASCADE",
"ProviderCoverage.providerId: INTEGER NOT NULL",
"ProviderCoverage.symbol: TEXT NOT NULL",
"ProviderEndpoint.capability: TEXT NOT NULL",
"ProviderEndpoint.createdAt: TEXT",
"ProviderEndpoint.id: INTEGER PK1",
"ProviderEndpoint.method: TEXT NOT NULL",
"ProviderEndpoint.minIntervalSeconds: INTEGER",
"ProviderEndpoint.pathTemplate: TEXT NOT NULL",
"ProviderEndpoint.providerId -> Provider.id ON DELETE CASCADE",
"ProviderEndpoint.providerId: INTEGER NOT NULL",
"ProviderEndpoint.queryTemplate: TEXT",
"ProviderEndpoint.quoteCurrency: TEXT",
"ProviderFixedRate.createdAt: TEXT",
"ProviderFixedRate.fiatPrice: REAL NOT NULL",
"ProviderFixedRate.providerId -> Provider.id ON DELETE CASCADE",
"ProviderFixedRate.providerId: INTEGER PK1",
"ProviderInputMap.createdAt: TEXT",
"ProviderInputMap.externalId: TEXT NOT NULL",
"ProviderInputMap.id: INTEGER PK1",
"ProviderInputMap.isCatchAll: INTEGER NOT NULL",
"ProviderInputMap.providerId -> Provider.id ON DELETE CASCADE",
"ProviderInputMap.providerId: INTEGER NOT NULL",
"ProviderInputMap.quoteUnit: TEXT",
"ProviderInputMap.symbol: TEXT",
"ProviderOutputMap.capability: TEXT NOT NULL",
"ProviderOutputMap.createdAt: TEXT",
"ProviderOutputMap.id: INTEGER PK1",
"ProviderOutputMap.internalField: TEXT NOT NULL",
"ProviderOutputMap.jsonPath: TEXT NOT NULL",
"ProviderOutputMap.providerId -> Provider.id ON DELETE CASCADE",
"ProviderOutputMap.providerId: INTEGER NOT NULL",
"ProviderOutputMap.transform: TEXT NOT NULL",
"ReconcileProposal.accountId -> Account.id ON DELETE CASCADE",
"ReconcileProposal.accountId: INTEGER NOT NULL",
"ReconcileProposal.amount: INTEGER NOT NULL",
"ReconcileProposal.assetId -> Asset.id ON DELETE CASCADE",
"ReconcileProposal.assetId: INTEGER NOT NULL",
"ReconcileProposal.detectedAt: TEXT NOT NULL",
"ReconcileProposal.direction: TEXT NOT NULL",
"ReconcileProposal.id: INTEGER PK1",
"ReconcileProposal.resolvedEventId -> Event.id ON DELETE SET NULL",
"ReconcileProposal.resolvedEventId: INTEGER",
"ReconcileProposal.status: TEXT NOT NULL",
"ReconcileProposal.suggestedType: TEXT NOT NULL",
"ReconcileProposal.transactionHash: TEXT",
"ReconcileProposal.walletTransactionId -> WalletTransaction.id ON DELETE CASCADE",
"ReconcileProposal.walletTransactionId: INTEGER",
"ScanLog.accountId -> Account.id ON DELETE CASCADE",
"ScanLog.accountId: INTEGER NOT NULL",
"ScanLog.addressesFound: INTEGER",
"ScanLog.balanceFound: INTEGER",
"ScanLog.id: INTEGER PK1",
"ScanLog.proposalsCreated: INTEGER",
"ScanLog.rawData: TEXT",
"ScanLog.scannedAt: TEXT NOT NULL",
"ScanLog.status: TEXT NOT NULL",
"Schema.id: INTEGER PK1",
"Schema.version: INTEGER NOT NULL",
"UNIQUE INDEX UxAssetNoteAssetId ON AssetNote(assetId)",
"UNIQUE INDEX UxConnectionActiveBtcExplorer ON Connection(kind) PARTIAL",
"UNIQUE INDEX UxDashboardProfileActive ON DashboardProfile(isActive) PARTIAL",
"UNIQUE INDEX UxHygieneAcceptanceFingerprint ON HygieneAcceptance(fingerprint)",
"UNIQUE INDEX UxPriceAbsenceAssetIdProviderIdDay ON PriceAbsence(assetId,providerId,day)",
"UNIQUE INDEX UxProviderInputMapCatchAll ON ProviderInputMap(providerId) PARTIAL",
"UNIQUE INDEX UxReconcileProposalLink ON ReconcileProposal(walletTransactionId,resolvedEventId) PARTIAL",
"UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId,txid) PARTIAL",
"UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId,txid) PARTIAL",
"UNIQUE INDEX sqlite_autoindex_ApiCallMetric_1 ON ApiCallMetric(providerId,capability,bucketMinute)",
"UNIQUE INDEX sqlite_autoindex_Asset_1 ON Asset(symbol) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_Connection_1 ON Connection(kind,searchName) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_DashboardProfile_1 ON DashboardProfile(searchName) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_DashboardWidget_1 ON DashboardWidget(profileId,instanceKey) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_InterestCredit_1 ON InterestCredit(scheduleId,periodEnd) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_InterestPeriod_1 ON InterestPeriod(scheduleId,startsOn) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_Label_1 ON Label(searchName) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_MetalProduct_1 ON MetalProduct(assetId,searchName) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_Preference_1 ON Preference(scope,key)",
"UNIQUE INDEX sqlite_autoindex_PriceHistory_1 ON PriceHistory(assetId,providerId,observedAt,resolution) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_PriceSource_1 ON PriceSource(assetId,capability,providerId) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_PriceSource_2 ON PriceSource(assetId,capability,rank) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_ProviderCoverage_1 ON ProviderCoverage(providerId,symbol) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_ProviderEndpoint_1 ON ProviderEndpoint(providerId,capability) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_ProviderInputMap_1 ON ProviderInputMap(providerId,symbol) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_Provider_1 ON Provider(key) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_Valuation_1 ON Valuation(assetId,valuedOn,accountId) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_WalletAddress_1 ON WalletAddress(address) [uit UNIQUE]",
"UNIQUE INDEX sqlite_autoindex_WalletTransactionEventLink_1 ON WalletTransactionEventLink(walletTransactionId,eventId) [uit UNIQUE]",
"Valuation.accountId -> Account.id ON DELETE NO ACTION",
"Valuation.accountId: INTEGER",
"Valuation.assetId -> Asset.id ON DELETE CASCADE",
"Valuation.assetId: INTEGER NOT NULL",
"Valuation.balance: INTEGER NOT NULL",
"Valuation.createdAt: TEXT",
"Valuation.fiatRate: REAL NOT NULL",
"Valuation.gapAcceptedAt: TEXT",
"Valuation.id: INTEGER PK1",
"Valuation.rateAt: TEXT NOT NULL",
"Valuation.source: TEXT NOT NULL",
"Valuation.updatedAt: TEXT",
"Valuation.valuedOn: TEXT NOT NULL",
"WalletAddress.accountId -> Account.id ON DELETE CASCADE",
"WalletAddress.accountId: INTEGER NOT NULL",
"WalletAddress.address: TEXT NOT NULL",
"WalletAddress.automaticLabel: TEXT",
"WalletAddress.balanceSats: INTEGER",
"WalletAddress.chainIndex: INTEGER",
"WalletAddress.createdAt: TEXT",
"WalletAddress.derivationIndex: INTEGER",
"WalletAddress.id: INTEGER PK1",
"WalletAddress.lastScannedAt: TEXT",
"WalletAddress.sourceId -> WalletSource.id ON DELETE CASCADE",
"WalletAddress.sourceId: INTEGER",
"WalletAddress.transactionCount: INTEGER",
"WalletSource.accountId -> Account.id ON DELETE CASCADE",
"WalletSource.accountId: INTEGER NOT NULL",
"WalletSource.balanceSats: INTEGER",
"WalletSource.connectionId -> Connection.id ON DELETE RESTRICT",
"WalletSource.connectionId: INTEGER",
"WalletSource.createdAt: TEXT NOT NULL",
"WalletSource.id: INTEGER PK1",
"WalletSource.kind: TEXT NOT NULL",
"WalletSource.lastScannedAt: TEXT",
"WalletSource.name: TEXT",
"WalletSource.scriptType: TEXT",
"WalletSource.updatedAt: TEXT",
"WalletSource.value: TEXT NOT NULL",
"WalletTransaction.accountId -> Account.id ON DELETE CASCADE",
"WalletTransaction.accountId: INTEGER NOT NULL",
"WalletTransaction.address: TEXT",
"WalletTransaction.blockHeight: INTEGER",
"WalletTransaction.blockTime: TEXT",
"WalletTransaction.createdAt: TEXT NOT NULL",
"WalletTransaction.direction: TEXT NOT NULL",
"WalletTransaction.feeSats: INTEGER",
"WalletTransaction.id: INTEGER PK1",
"WalletTransaction.kind: TEXT",
"WalletTransaction.labelId -> Label.id ON DELETE SET NULL",
"WalletTransaction.labelId: INTEGER",
"WalletTransaction.layer: TEXT NOT NULL",
"WalletTransaction.memo: TEXT",
"WalletTransaction.sourceId -> WalletSource.id ON DELETE CASCADE",
"WalletTransaction.sourceId: INTEGER",
"WalletTransaction.txid: TEXT NOT NULL",
"WalletTransaction.valueSats: INTEGER NOT NULL",
"WalletTransactionEventLink.createdAt: TEXT NOT NULL",
"WalletTransactionEventLink.eventId -> Event.id ON DELETE CASCADE",
"WalletTransactionEventLink.eventId: INTEGER NOT NULL",
"WalletTransactionEventLink.id: INTEGER PK1",
"WalletTransactionEventLink.method: TEXT NOT NULL",
"WalletTransactionEventLink.walletTransactionId -> WalletTransaction.id ON DELETE CASCADE",
"WalletTransactionEventLink.walletTransactionId: INTEGER NOT NULL",
];
+48
View File
@@ -0,0 +1,48 @@
// Generated, do not edit. sha256:af48787cb89b51ea0deb7319053f63d99c76dc61ed23f69fff32ffdd48d87f38
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { toSearchName } from '../../shared/search-name.js';
describe('toSearchName', () => {
it('maakt er kleine letters van', () => {
assert.equal(toSearchName('Bitcoin'), 'bitcoin');
assert.equal(toSearchName('WERK'), 'werk');
});
it('trimt spaties aan de randen', () => {
assert.equal(toSearchName(' Kluis '), 'kluis');
});
it('haalt diakritische tekens weg', () => {
assert.equal(toSearchName('Café'), 'cafe');
assert.equal(toSearchName('Bäcker'), 'backer');
});
it('laat spaties BINNENIN staan', () => {
assert.equal(toSearchName('Baar 1 kg'), 'baar 1 kg');
});
it('laat leestekens en cijfers ongemoeid', () => {
assert.equal(toSearchName('mempool.space (publiek)'), 'mempool.space (publiek)');
assert.equal(toSearchName('Baar 1 kg'), 'baar 1 kg');
});
it('geeft lege tekst voor null en undefined in plaats van te gooien', () => {
assert.equal(toSearchName(null), '');
assert.equal(toSearchName(undefined), '');
assert.equal(toSearchName(''), '');
assert.equal(toSearchName(' '), '');
});
it('is stabiel: nog een keer normaliseren verandert niets', () => {
for (const naam of ['Café', ' Werk ', 'Baar 1 kg', 'ÉÉN']) {
assert.equal(toSearchName(toSearchName(naam)), toSearchName(naam));
}
});
it('maakt van twee schrijfwijzen dezelfde sleutel', () => {
assert.equal(toSearchName('Werk'), toSearchName('WERK'));
assert.equal(toSearchName('Werk'), toSearchName(' werk '));
});
});
+268
View File
@@ -0,0 +1,268 @@
// Generated, do not edit. sha256:3914f1ddc9004d83b8401e7c0b8dbbc55835029676e9b46f33978bfb59e5b5ee
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { openDatabase, closeDatabase, database, run } from '../../server/db/conn.mjs';
import { SCHEMA } from '../../server/db/schema.js';
import { seedAssets, seedPriceSource, seedDashboardProfile, seedProviders, seedPreferences } from '../../server/db/seeds.js';
import { DASHBOARD, PREFERENCES } from '../../shared/config.js';
const verseDatabase = (munt) => {
closeDatabase(); openDatabase(':memory:'); run(SCHEMA);
run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, ?, '2026-01-01T00:00:00Z')`, [munt]);
};
beforeEach(() => verseDatabase('EUR'));
afterEach(() => closeDatabase());
const profielen = () => database().prepare(`SELECT * FROM DashboardProfile ORDER BY id`).all().map(r => ({ ...r }));
const widgets = () => database().prepare(`SELECT * FROM DashboardWidget ORDER BY instanceKey`).all().map(r => ({ ...r }));
describe('seedDashboardProfile', () => {
it('maakt één actief systeemprofiel', () => {
seedDashboardProfile();
const p = profielen();
assert.equal(p.length, 1);
assert.equal(p[0].name, 'Standaard');
assert.equal(p[0].isSystem, 1);
assert.equal(p[0].isActive, 1);
assert.equal(p[0].sortOrder, 0);
assert.equal(p[0].searchName, 'standaard', 'afgeleid via toSearchName, niet met de hand');
});
it('zet de standaardindeling als rijen neer', () => {
seedDashboardProfile();
assert.deepEqual(widgets().map(w => w.type).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.type).sort());
assert.deepEqual(widgets().map(w => w.instanceKey).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.instId).sort());
});
it('laat hoogte en rij open, want die komen uit de meting', () => {
seedDashboardProfile();
for (const w of widgets()) {
assert.equal(w.spanHeight, null, `${w.instanceKey} heeft al een hoogte`);
assert.equal(w.gridY, 0);
assert.ok(w.spanWidth > 0, 'de breedte komt wél uit de registry: die wordt gekozen');
}
});
it('neemt de voorkeurskolom mee, zodat Meldingen rechts begint', () => {
seedDashboardProfile();
const kolom = Object.fromEntries(widgets().map(w => [w.instanceKey, w.gridX]));
assert.equal(kolom.clocks, undefined, 'geen wereldklokken bij een verse installatie');
assert.equal(kolom.notifications, DASHBOARD.GRID_COLUMNS - DASHBOARD.WIDGETS.notifications.defaultSpan);
});
it('koppelt de widgets aan het profiel en laat assetId leeg', () => {
seedDashboardProfile();
const id = profielen()[0].id;
for (const w of widgets()) {
assert.equal(w.profileId, id);
assert.equal(w.assetId, null, 'geen enkele standaardwidget hangt aan één asset');
assert.equal(w.settings, null, 'en geen van hen heeft parameters');
}
});
it('is idempotent', () => {
seedDashboardProfile();
seedDashboardProfile();
assert.equal(profielen().length, 1);
assert.equal(widgets().length, DASHBOARD.DEFAULT_LAYOUT.length, 'geen dubbele widget-rijen');
});
it('vertelt of hij iets heeft aangemaakt', () => {
assert.equal(seedDashboardProfile(), true, 'eerste keer: aangemaakt');
assert.equal(seedDashboardProfile(), false, 'tweede keer: niets te doen');
});
it('loopt niet op de unieke index als er al een ander actief profiel is', () => {
run(`INSERT INTO DashboardProfile (name, searchName, sortOrder, isActive, isSystem, createdAt, updatedAt)
VALUES ('Eigen','eigen',0,1,0,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`);
seedDashboardProfile();
const p = profielen();
assert.equal(p.length, 2);
assert.equal(p.filter(x => x.isActive).length, 1, 'nog steeds precies één actief');
assert.equal(p.find(x => x.isActive).name, 'Eigen', 'de bestaande keuze blijft staan');
assert.equal(p.find(x => x.isSystem).isActive, 0);
});
});
describe('seeds: de vaste-koers-provider en het EUR-asset', () => {
const provider = () => database().prepare(`SELECT * FROM Provider WHERE key = 'fixed'`).get();
const bronkeuze = () => database().prepare(
`SELECT capability, rank, status FROM PriceSource
WHERE assetId = (SELECT id FROM Asset WHERE symbol = 'EUR')
ORDER BY capability`).all().map(r => ({ ...r }));
it('zet de provider neer met een vaste koers van 1,00, zijn dekking en zijn twee capabilities', () => {
seedProviders();
const p = provider();
assert.equal(p.isSystem, 1);
assert.equal(p.baseUrl, '', 'geen URL, want er gaat geen verzoek uit');
assert.equal(database().prepare(`SELECT fiatPrice FROM ProviderFixedRate WHERE providerId = ?`).get(p.id).fiatPrice, 1);
assert.deepEqual(
database().prepare(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`).all(p.id).map(r => r.symbol),
['EUR']);
assert.deepEqual(
database().prepare(`SELECT capability FROM ProviderEndpoint WHERE providerId = ? ORDER BY capability`)
.all(p.id).map(r => r.capability),
['history_point', 'spot'], 'geen daily_history: een vaste koers heeft geen reeks');
});
it('geeft alleen de vaste-koers-provider een vaste koers', () => {
seedProviders();
assert.deepEqual(
database().prepare(`SELECT p.key FROM ProviderFixedRate f JOIN Provider p ON p.id = f.providerId`).all()
.map(r => r.key),
['fixed']);
});
it('zet het EUR-asset neer in centen', () => {
seedAssets();
const eur = database().prepare(`SELECT * FROM Asset WHERE symbol = 'EUR'`).get();
assert.equal(eur.kind, 'fiat');
assert.equal(eur.unit, 'EUR');
assert.equal(eur.subunit, 'cent', 'de cent is de opslag-eenheid van 10^decimals');
assert.equal(eur.decimals, 2);
assert.equal(eur.displayUnit, 'unit', 'niemand leest een saldo in centen');
});
it('koppelt EUR aan de vaste koers voor de spot en de historie', () => {
seedProviders();
seedAssets();
seedPriceSource(null, 'confirmed');
assert.deepEqual(bronkeuze(), [
{ capability: 'history', rank: 1, status: 'confirmed' },
{ capability: 'spot', rank: 1, status: 'confirmed' },
]);
});
it('geeft een bron alleen de keuze die hij kan leveren', () => {
seedProviders();
seedAssets();
database().exec(`DELETE FROM ProviderEndpoint WHERE capability <> 'spot'
AND providerId = (SELECT id FROM Provider WHERE key = 'fixed')`);
seedPriceSource(null, 'confirmed');
assert.deepEqual(bronkeuze(), [{ capability: 'spot', rank: 1, status: 'confirmed' }]);
});
it('is idempotent: twee keer zaaien levert één provider en één asset', () => {
seedProviders(); seedAssets(); seedPriceSource(null, 'confirmed');
seedProviders(); seedAssets(); seedPriceSource(null, 'confirmed');
const n = (q) => database().prepare(q).get().n;
assert.equal(n(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'fixed'`), 1);
assert.equal(n(`SELECT COUNT(*) AS n FROM Asset WHERE symbol = 'EUR'`), 1);
assert.equal(n(`SELECT COUNT(*) AS n FROM ProviderFixedRate`), 1);
assert.equal(bronkeuze().length, 2);
});
});
describe('seeds per rekenvaluta', () => {
const zaai = () => { seedProviders(); seedAssets(); seedPriceSource(null, 'confirmed'); };
const dekking = (key) => database().prepare(
`SELECT symbol FROM ProviderCoverage WHERE providerId = (SELECT id FROM Provider WHERE key = ?) ORDER BY symbol`)
.all(key).map(r => r.symbol);
const startbron = (symbool) => database().prepare(
`SELECT DISTINCT Provider.key FROM PriceSource
JOIN Provider ON Provider.id = PriceSource.providerId
JOIN Asset ON Asset.id = PriceSource.assetId
WHERE Asset.symbol = ?`).all(symbool).map(r => r.key);
const fiat = () => database().prepare(`SELECT symbol, name, subunit, decimals FROM Asset WHERE kind = 'fiat' ORDER BY symbol`)
.all().map(r => ({ ...r }));
it('in euro: de vaste koers op de euro, en de dollar voor zijn koers', () => {
zaai();
assert.deepEqual(dekking('fixed'), ['EUR']);
assert.deepEqual(fiat().map(a => a.symbol), ['EUR', 'USD'], 'geen derde munt als de rekenvaluta al één van de twee is');
});
it('in dollars: de vaste koers op de dollar, en de euro zonder eigen bron', () => {
verseDatabase('USD');
zaai();
assert.deepEqual(dekking('fixed'), ['USD']);
assert.deepEqual(startbron('USD'), ['fixed']);
assert.deepEqual(startbron('EUR'), [], 'de koers van de euro komt van de dienst');
});
it('geeft een later toegevoegd BTC de bron die de portefeuille al heeft', () => {
zaai();
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('BTC', 'Bitcoin', 'bitcoin', 'crypto', 'BTC', 'sat', 8, 'subunit')`);
const id = database().prepare(`SELECT id FROM Asset WHERE symbol = 'BTC'`).get().id;
seedPriceSource(id, 'confirmed');
assert.deepEqual(startbron('BTC'), [], 'een verse database heeft geen mempool-provider');
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES ('mempool', 'mempool', 'mempool', 'https://mempool.space', 'none', 0, 1, 1, 0, '2026-01-01T00:00:00Z')`);
const pid = database().prepare(`SELECT id FROM Provider WHERE key = 'mempool'`).get().id;
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, 'spot', 'GET', '/api/v1/prices')`, [pid]);
seedPriceSource(id, 'confirmed');
assert.deepEqual(startbron('BTC'), ['mempool']);
});
it('geeft de assets uit de onboarding geen eigen bron: hun koers komt van de dienst', async () => {
const { ASSET_DEFINITIONS } = await import('../../shared/config.js');
zaai();
for (const d of Object.values(ASSET_DEFINITIONS)) {
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`,
[d.symbol, d.name, d.name.toLowerCase(), d.kind, d.unit, d.subunit, d.decimals, d.displayUnit]);
seedPriceSource(database().prepare(`SELECT id FROM Asset WHERE symbol = ?`).get(d.symbol).id, 'confirmed');
assert.deepEqual(startbron(d.symbol), [], `${d.symbol} kreeg een eigen bron`);
}
});
it('zet alleen de systeemproviders neer', () => {
zaai();
const rijen = database().prepare(`SELECT key, isSystem FROM Provider ORDER BY key`).all().map(r => ({ ...r }));
assert.deepEqual(rijen.map(r => r.key), ['atreides_services', 'fixed', 'manual']);
assert.ok(rijen.every(r => r.isSystem === 1));
});
it('in ponden: drie fiat-assets, en alleen de rekenvaluta met een bron', () => {
verseDatabase('GBP');
zaai();
const munten = fiat();
assert.deepEqual(munten.map(({ name, ...rest }) => rest), [
{ symbol: 'EUR', subunit: 'cent', decimals: 2 },
{ symbol: 'GBP', subunit: null, decimals: 2 },
{ symbol: 'USD', subunit: 'cent', decimals: 2 },
]);
const pond = munten.find(a => a.symbol === 'GBP').name;
assert.match(pond, /^[A-Z].*pond$/, `'${pond}' is geen Nederlandse naam met hoofdletter`);
assert.deepEqual(dekking('fixed'), ['GBP']);
assert.deepEqual(startbron('GBP'), ['fixed']);
assert.deepEqual(startbron('USD'), []);
});
it('zet in een verse database alleen de fiat-assets neer, geen crypto en geen metaal', () => {
zaai();
const soorten = database().prepare(`SELECT DISTINCT kind FROM Asset`).all().map(r => r.kind);
assert.deepEqual(soorten, ['fiat']);
});
it('geeft een munt zonder centen ook geen centen', () => {
verseDatabase('JPY');
seedAssets();
assert.equal(fiat().find(a => a.symbol === 'JPY').decimals, 0);
});
it('weigert te zaaien zonder Portfolio-rij, in plaats van de euro aan te nemen', () => {
closeDatabase(); openDatabase(':memory:'); run(SCHEMA);
assert.throws(() => seedAssets(), /Portfolio row/);
assert.throws(() => seedProviders(), /Portfolio row/);
assert.throws(() => seedPriceSource(null, 'confirmed'), /Portfolio row/);
});
});
describe('seedPreferences', () => {
const waarde = (scope, key) => JSON.parse(database().prepare(
`SELECT value FROM Preference WHERE scope = ? AND key = ?`).get(scope, key).value);
it('zet de dienst vooraan in een verse database', () => {
seedPreferences();
assert.equal(waarde('app', 'market_spot'), 'first');
assert.equal(PREFERENCES.app.market_spot.initial, 'off', 'de terugval van een bestaande portefeuille');
});
it('laat de andere voorkeuren op hun initial', () => {
seedPreferences();
assert.equal(waarde('app', 'result_signs'), PREFERENCES.app.result_signs.initial);
});
});
+124
View File
@@ -0,0 +1,124 @@
// Generated, do not edit. sha256:e2e0937b7b3c760761ff1e6af43b751d527084ca1706a0de98999069ed45ca25
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { toUtcIso, toLocalIso, localDate, localDateNl, dayNl, localDateTimeNl, localTime, localDateTime, nowUtcIso, nowLocalIso, setDateLocale } from '../../shared/time.js';
describe('tijd: opslaan (naïef lokaal naar UTC)', () => {
it('rekent zomertijd en wintertijd verschillend om', () => {
assert.equal(toUtcIso('2026-06-20T14:30:00'), '2026-06-20T12:30:00Z');
assert.equal(toUtcIso('2026-01-15T14:30:00'), '2026-01-15T13:30:00Z');
});
it('accepteert een datetime-local-waarde zonder seconden', () => {
assert.equal(toUtcIso('2026-06-20T14:30'), '2026-06-20T12:30:00Z');
});
it('laat een waarde die al UTC is ongemoeid, dus omzetten is idempotent', () => {
const al = '2026-06-20T12:30:00.000Z';
assert.equal(toUtcIso(al), al);
assert.equal(toUtcIso(toUtcIso(al)), al);
});
it('raakt een kalenderdatum en lege waarden niet aan', () => {
assert.equal(toUtcIso('2026-06-20'), '2026-06-20');
assert.equal(toUtcIso(null), null);
assert.equal(toUtcIso(''), '');
});
});
describe('tijd: heen-en-terug', () => {
it('komt exact terug, ook op de twee zomertijdgrenzen', () => {
for (const t of ['2026-06-20T14:30:00', '2026-01-15T00:00:00', '2026-03-29T04:00:00',
'2026-10-25T04:00:00', '2025-12-31T23:59:59', '2024-02-29T12:00:00']) {
assert.equal(toLocalIso(toUtcIso(t)), t, `heen-en-terug faalt op ${t}`);
}
});
it('houdt het moment gelijk, ook als de kalenderdag verschuift', () => {
const utc = toUtcIso('2026-06-20T00:00:00');
assert.equal(utc, '2026-06-19T22:00:00Z');
assert.equal(toLocalIso(utc), '2026-06-20T00:00:00');
});
});
describe('tijd: tonen', () => {
it('toont de lokale dag en niet de UTC-dag', () => {
assert.equal(localDate('2026-06-19T22:00:00Z'), '2026-06-20');
assert.equal(localTime('2026-06-19T22:00:00Z'), '00:00');
assert.equal(localDateTime('2026-06-19T22:00:00Z'), '2026-06-20 00:00');
});
it('toont een zomers tijdstempel twee uur later dan de UTC-tekst', () => {
assert.equal(localTime('2025-11-01T14:52:43.000Z'), '15:52');
assert.equal(localTime('2026-06-20T12:30:00.000Z'), '14:30');
});
it('geeft lege tekst voor een lege waarde in plaats van te gooien', () => {
for (const leeg of [null, undefined, '']) {
assert.equal(localDate(leeg), '');
assert.equal(localTime(leeg), '');
assert.equal(localDateTime(leeg), '');
}
});
});
describe('tijd: nu', () => {
it('geeft nowUtcIso in UTC en nowLocalIso zonder zone', () => {
assert.match(nowUtcIso(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
assert.match(nowLocalIso(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
});
it('geeft voor hetzelfde moment dezelfde tijd in beide vormen', () => {
const utc = nowUtcIso();
assert.equal(toLocalIso(utc).slice(0, 16), nowLocalIso().slice(0, 16));
});
});
describe('localDateNl', () => {
it('zet de dag vooraan, met streepjes', () => {
assert.equal(localDateNl('2026-06-20T12:30:00Z'), '20-06-2026');
});
it('vult de dag en de maand aan tot twee cijfers', () => {
assert.equal(localDateNl('2026-01-01T12:00:00Z'), '01-01-2026');
});
it('volgt de LOKALE dag en niet de UTC-dag', () => {
assert.equal(localDateNl('2026-06-20T22:30:00Z'), '21-06-2026');
assert.equal(localDate('2026-06-20T22:30:00Z'), '2026-06-21', 'dezelfde dag als localDate');
});
it('geeft lege tekst op een LEGE waarde, en gooit op onleesbare invoer', () => {
for (const leeg of [null, undefined, '']) assert.equal(localDateNl(leeg), '');
assert.throws(() => localDateNl('geen datum'), /Onleesbaar tijdstempel/);
});
});
describe('dayNl en localDateTimeNl', () => {
it('draait een kalenderdatum om zonder tijdzone', () => {
assert.equal(dayNl('2026-01-01'), '01-01-2026');
assert.equal(dayNl('2026-12-31'), '31-12-2026');
});
it('geeft leeg op leeg en laat iets wat geen datum is staan', () => {
for (const leeg of [null, undefined, '']) assert.equal(dayNl(leeg), '');
assert.equal(dayNl('onbekend'), 'onbekend');
});
it('zet datum en klok samen neer, in lokale tijd', () => {
assert.equal(localDateTimeNl('2026-06-19T22:00:00Z'), '20-06-2026 00:00');
assert.equal(localDateTimeNl(null), '');
});
it('volgt de taal van de app, en terug', () => {
setDateLocale('en-US');
try {
assert.equal(dayNl('2026-06-08'), '06/08/2026');
assert.equal(localDateTimeNl('2026-06-19T22:00:00Z'), '06/20/2026 00:00');
} finally {
setDateLocale('nl-NL');
}
assert.equal(dayNl('2026-06-08'), '08-06-2026', 'terug naar Nederlands, ook met de opmaak in de cache');
});
});
+65
View File
@@ -0,0 +1,65 @@
// Generated, do not edit. sha256:fefc7c108aded894b1e2af3e5fbbedc3eac1b26a65f9c5ddfd7154896dda96a0
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import { openBaselineDb } from '../helpers/baseline-db.mjs';
import { CONFIG } from '../../shared/config.js';
const EIGEN_ONTSTAAN = {
ScanLog: 'scannedAt',
ReconcileProposal: 'detectedAt',
ImportBatch: 'importedAt',
AiToolCall: 'occurredAt',
AiToolCallMessage: 'occurredAt',
BusSignal: 'occurredAt',
ChangeLog: 'occurredAt',
IntegrationCall: 'occurredAt',
};
const GEEN_RIJ_IDENTITEIT = ['ApiCallMetric', 'Schema'];
let KOLOMMEN;
before(() => {
const db = openBaselineDb();
KOLOMMEN = {};
for (const t of CONFIG.DB_TABLES) {
KOLOMMEN[t] = db.prepare(`PRAGMA table_info(${t})`).all().map(k => k.name);
}
db.close();
});
describe('tijdstempels: createdAt waar het ontstaan nog nergens staat', () => {
it('geeft elke tabel een createdAt, op de vrijgestelde na', () => {
const missend = CONFIG.DB_TABLES.filter(t =>
!(t in EIGEN_ONTSTAAN) && !GEEN_RIJ_IDENTITEIT.includes(t) && !KOLOMMEN[t].includes('createdAt'));
assert.deepEqual(missend, [],
'een nieuwe tabel hoort een createdAt te krijgen, of een gemotiveerde vrijstelling hierboven');
});
it('houdt de vrijgestelde tabellen vrij, want anders staan er twee namen voor hetzelfde moment', () => {
for (const [tabel, kolom] of Object.entries(EIGEN_ONTSTAAN)) {
assert.ok(KOLOMMEN[tabel].includes(kolom), `${tabel} zou zijn ontstaan in ${kolom} bewaren`);
assert.ok(!KOLOMMEN[tabel].includes('createdAt'),
`${tabel} heeft ${kolom} én createdAt: dat is een tweede waarheid over hetzelfde moment`);
}
for (const tabel of GEEN_RIJ_IDENTITEIT) {
assert.ok(!KOLOMMEN[tabel].includes('createdAt'), `${tabel} heeft geen rij om een moment aan te hangen`);
}
});
});
describe('tijdstempels: updatedAt waar een wijziging betekenis heeft', () => {
it('geeft elke getrackte tabel een updatedAt', () => {
const missend = CONFIG.DB_HISTORY.TRACKED.filter(t => !KOLOMMEN[t].includes('updatedAt'));
assert.deepEqual(missend, [],
'een tabel die meedoet in het wijzigingslogboek hoort te kunnen zeggen wanneer hij wijzigde');
});
it('geeft hem NIET aan een tabel waar niemand die vraag stelt', () => {
const teveel = CONFIG.DB_TABLES.filter(t =>
!CONFIG.DB_HISTORY.TRACKED.includes(t) && t !== 'PriceHistory'
&& t !== 'DashboardProfile' && KOLOMMEN[t].includes('updatedAt'));
assert.deepEqual(teveel, [],
'updatedAt op een tabel die niet meedoet: die kolom blijft per definitie leeg');
});
});