Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
];
|
||||
@@ -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 '));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user