Files
AtreidesAssetsFree/tests/db/migrations.test.mjs
T
2026-10-29 11:57:29 +01:00

2533 lines
136 KiB
JavaScript

// Generated, do not edit. sha256:4ce5c52e16e739212783761b521116e3496e8239a0c021ec82c742e179e252d6
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { openDatabase, closeDatabase, run, all, one } from '../../server/db/conn.mjs';
import { readVersion, runMigrations, BASELINE_VERSION } from '../../server/db/migrations.js';
import { openBaselineDb } from '../helpers/baseline-db.mjs';
import { CONFIG } from '../../shared/config.js';
function bronTabellenStomp() {
run(`CREATE TABLE Provider (
id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL,
baseUrl TEXT NOT NULL, authenticationHeaderName TEXT, authenticationParameterName TEXT,
authenticationKind TEXT NOT NULL, authenticationRequired INTEGER NOT NULL,
isActive INTEGER NOT NULL, isSeed INTEGER NOT NULL, isSystem INTEGER NOT NULL,
createdAt TEXT NOT NULL, UNIQUE (key)
) STRICT;`);
run(`CREATE TABLE ProviderCoverage (
id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
symbol TEXT NOT NULL, UNIQUE (providerId, symbol)
) STRICT;`);
run(`CREATE TABLE ProviderEndpoint (
id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
pathTemplate TEXT NOT NULL, queryTemplate TEXT, capability TEXT NOT NULL, method TEXT NOT NULL,
UNIQUE (providerId, capability)
) STRICT;`);
run(`CREATE TABLE ProviderOutputMap (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
jsonPath TEXT NOT NULL,
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')),
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram'))
) STRICT;`);
run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`);
run(`CREATE TABLE ConsumerSource (
id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
consumerKey TEXT NOT NULL, capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL,
UNIQUE (consumerKey, assetId, capability, providerId),
UNIQUE (consumerKey, assetId, capability, rank)
) STRICT;`);
}
function accountTabelVoor72() {
run(`CREATE TABLE Account (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT,
kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')),
isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)),
scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)),
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
closedAt TEXT,
CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL))
) STRICT;`);
run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`);
}
function dashboardWidgetStomp() {
run(`CREATE TABLE DashboardWidget (
id INTEGER PRIMARY KEY, profileId INTEGER NOT NULL, type TEXT NOT NULL
) STRICT;`);
}
function valuationTabelVoor74() {
run(`CREATE TABLE Valuation (
id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
balance INTEGER NOT NULL,
eurRate REAL NOT NULL,
source TEXT NOT NULL CHECK (source IN ('onchain','manual','statement')),
valuedOn TEXT NOT NULL,
rateAt TEXT NOT NULL,
UNIQUE (assetId, valuedOn, accountId)
) STRICT;`);
run(`CREATE INDEX IxValuationAccountId ON Valuation(accountId)`);
}
function interestScheduleTabelVoor75() {
run(`CREATE TABLE InterestSchedule (
id INTEGER PRIMARY KEY,
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
principalAssetId INTEGER REFERENCES Asset(id) ON DELETE NO ACTION,
payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
note TEXT,
principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0),
annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0),
rateKind TEXT NOT NULL CHECK (rateKind IN ('fixed','variable')),
creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN ('term_end','annual','monthly','weekly')),
creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6),
creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31),
startsOn TEXT NOT NULL,
endsOn TEXT,
createdAt TEXT NOT NULL,
CHECK (rateKind = 'fixed' OR (principalAmount IS NULL AND annualRate IS NULL)),
CHECK (creditWeekday IS NULL OR creditCadence = 'weekly'),
CHECK (creditDayOfMonth IS NULL OR creditCadence = 'monthly'),
CHECK (endsOn IS NULL OR endsOn >= startsOn)
) STRICT;`);
run(`CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)`);
run(`CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)`);
run(`CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)`);
}
function opzet({ versie = null } = {}) {
openDatabase(':memory:');
if (versie !== null) {
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (?)`, [versie]);
run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT;`);
run(`CREATE TABLE Notification (
id INTEGER PRIMARY KEY,
count INTEGER NOT NULL CHECK (count >= 0),
highWater INTEGER NOT NULL CHECK (highWater >= 0),
kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal')),
dismissedAt TEXT,
createdAt TEXT NOT NULL
) STRICT;`);
run(`CREATE INDEX IxNotificationKind ON Notification(kind)`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT,
unit TEXT NOT NULL,
subunit TEXT,
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
decimals INTEGER NOT NULL CHECK (decimals >= 0),
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')),
UNIQUE (symbol),
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
run(`CREATE TABLE Event (
id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION,
productCount INTEGER CHECK (productCount IS NULL OR productCount > 0),
importReference TEXT,
externalAddress TEXT,
note TEXT,
amount INTEGER NOT NULL CHECK (amount > 0),
eurPrice REAL,
eurFee REAL,
unitFee INTEGER,
type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest')),
source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')),
occurredAt TEXT NOT NULL,
createdAt TEXT NOT NULL,
CHECK ((productId IS NULL) = (productCount IS NULL)),
CHECK (type NOT IN ('buy','sell') OR eurPrice IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxEventAssetId ON Event(assetId)`);
run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`);
run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`);
run(`CREATE INDEX IxEventProductId ON Event(productId)`);
run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`);
run(`CREATE INDEX IxEventType ON Event(type)`);
run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`);
run(`CREATE INDEX IxEventImportReference ON Event(importReference)`);
accountTabelVoor72();
run(`CREATE TABLE MetalProduct (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;`);
bronTabellenStomp();
dashboardWidgetStomp();
valuationTabelVoor74();
interestScheduleTabelVoor75();
}
}
function zetRijen({ decimals = 8, eurPrice = 30000, eurFee = null, unitFee = null } = {}) {
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind)
VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',?,'crypto')`, [decimals]);
run(`INSERT INTO Event (id, assetId, amount, eurPrice, eurFee, unitFee, type, source, occurredAt, createdAt)
VALUES (1, 1, 100000, ?, ?, ?, 'buy', 'manual', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
[eurPrice, eurFee, unitFee]);
}
const tabellen = () => all(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).map(r => r.name);
beforeEach(() => closeDatabase());
afterEach(() => closeDatabase());
describe('migraties: de versie lezen', () => {
it('leest de versie uit Schema', () => {
opzet({ versie: 60 });
assert.equal(readVersion(), 60);
});
it('zegt bij een database zonder Schema-tabel dat hij te oud is, en verwijst naar het script', () => {
opzet();
assert.throws(() => readVersion(), /from before the new layout/);
assert.throws(() => readVersion(), /baseline-convert/);
});
});
describe('migraties: de drie takken van runMigrations', () => {
it('doet niets als de versie al klopt', () => {
opzet({ versie: CONFIG.SCHEMA_VERSION });
const voor = tabellen();
assert.equal(runMigrations(), false, 'niets gemigreerd, dus ook niets te persisteren');
assert.deepEqual(tabellen(), voor, 'en geen enkele schrijfactie op het schema');
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION, 'de versie staat er onveranderd');
});
it('weigert een database die nieuwer is dan de app', () => {
opzet({ versie: CONFIG.SCHEMA_VERSION + 1 });
assert.throws(() => runMigrations(), /newer than the app/);
});
it('weigert een database van vóór de baseline met een begrijpelijke melding', () => {
opzet({ versie: BASELINE_VERSION - 1 });
assert.throws(() => runMigrations(), /from before the new layout/);
assert.throws(() => runMigrations(), /baseline-convert/);
try { runMigrations(); } catch (e) {
assert.equal(/Migratie naar versie/.test(e.message), false,
'juist NIET de oude melding: die stuurt iemand op zoek naar een migratie die niet bestaat');
}
});
it('noemt de gevonden versie in de melding', () => {
opzet({ versie: 42 });
assert.throws(() => runMigrations(), /version 42/);
});
});
describe('migratie 69: de meldingssoort hernoemen, en wat er met bestaande rijen gebeurt', () => {
function opzet68() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (68)`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT,
unit TEXT NOT NULL,
subunit TEXT,
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')),
UNIQUE (symbol),
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
run(`CREATE TABLE Notification (
id INTEGER PRIMARY KEY,
count INTEGER NOT NULL CHECK (count >= 0),
highWater INTEGER NOT NULL CHECK (highWater >= 0),
kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_command')),
dismissedAt TEXT,
createdAt TEXT NOT NULL
) STRICT;`);
run(`CREATE INDEX IxNotificationKind ON Notification(kind)`);
bronTabellenStomp();
dashboardWidgetStomp();
valuationTabelVoor74();
interestScheduleTabelVoor75();
accountTabelVoor72();
}
const meldingen = () => all(`SELECT id, kind, count, highWater FROM Notification ORDER BY id`);
it('gooit een bestaande ai_command-melding weg en laat de andere staan', () => {
opzet68();
run(`INSERT INTO Notification (kind, count, highWater, createdAt)
VALUES ('ai_command', 7, 99, '2026-08-18T12:00:00.000Z')`);
run(`INSERT INTO Notification (kind, count, highWater, createdAt)
VALUES ('reconcile', 3, 12, '2026-08-18T12:00:00.000Z')`);
assert.equal(runMigrations(), true);
assert.deepEqual(meldingen(), [{ id: 2, kind: 'reconcile', count: 3, highWater: 12 }],
'alleen de reconcile-melding blijft, met zijn eigen high-water');
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('kent de nieuwe soort en weigert de oude', () => {
opzet68();
runMigrations();
assert.throws(() => run(`INSERT INTO Notification (kind, count, highWater, createdAt)
VALUES ('ai_command', 1, 1, '2026-08-19T12:00:00.000Z')`), /CHECK/i);
assert.doesNotThrow(() => run(`INSERT INTO Notification (kind, count, highWater, createdAt)
VALUES ('ai_proposal', 1, 1, '2026-08-19T12:00:00.000Z')`));
});
it('zet de index terug, want die verdwijnt met de oude tabel', () => {
opzet68();
runMigrations();
assert.deepEqual(all(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'Notification'`),
[{ name: 'IxNotificationKind' }]);
});
});
describe('migratie 70: Asset.kind krijgt fiat erbij', () => {
function opzet69() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (69)`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT,
unit TEXT NOT NULL,
subunit TEXT,
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')),
UNIQUE (symbol),
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
bronTabellenStomp();
dashboardWidgetStomp();
valuationTabelVoor74();
interestScheduleTabelVoor75();
accountTabelVoor72();
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind)
VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',8,'crypto')`);
}
const insertKind = (kind) => run(
`INSERT INTO Asset (symbol, name, searchName, unit, displayUnit, decimals, kind)
VALUES ('X${kind}','X','x','X','unit',2,'${kind}')`);
it('laat fiat toe en weigert een soort die niet bestaat', () => {
opzet69();
assert.throws(() => insertKind('fiat'), /CHECK/i, 'op 69 bestaat de soort nog niet');
runMigrations();
assert.doesNotThrow(() => insertKind('fiat'));
assert.throws(() => insertKind('onzin'), /CHECK/i);
});
it('neemt de bestaande assets mee en zet de index terug', () => {
opzet69();
runMigrations();
assert.deepEqual(all(`SELECT id, symbol, kind, decimals FROM Asset`),
[{ id: 1, symbol: 'BTC', kind: 'crypto', decimals: 8 },
{ id: 2, symbol: 'EUR', kind: 'fiat', decimals: 2 },
{ id: 3, symbol: 'USD', kind: 'fiat', decimals: 2 }]);
assert.deepEqual(
all(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'Asset' AND name NOT LIKE 'sqlite_%'`),
[{ name: 'IxAssetSearchName' }]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('houdt de andere CHECKs van die tabel overeind', () => {
opzet69();
runMigrations();
assert.throws(() => run(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/i);
assert.throws(() => run(`UPDATE Asset SET displayUnit = 'subunit', subunit = NULL WHERE id = 1`), /CHECK/i);
});
});
describe('migratie 71: de vaste-koers-provider en het EUR-asset', () => {
function opzet70() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (70)`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL,
description TEXT, unit TEXT NOT NULL, subunit TEXT,
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;`);
bronTabellenStomp();
dashboardWidgetStomp();
valuationTabelVoor74();
interestScheduleTabelVoor75();
accountTabelVoor72();
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind)
VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',8,'crypto')`);
}
const provider = () => one(`SELECT id, key, name, baseUrl, isActive, isSystem, isSeed FROM Provider WHERE key = 'fixed'`);
const bronkeuze = () => all(`SELECT capability, Provider.key AS bron, rank, status FROM PriceSource
JOIN Provider ON Provider.id = PriceSource.providerId
WHERE assetId = (SELECT id FROM Asset WHERE symbol = 'EUR')
ORDER BY capability, rank`);
it('zet de provider neer met een vaste koers van 1,00, zijn dekking en zijn twee capabilities', () => {
opzet70();
runMigrations();
const p = provider();
assert.equal(p.isSystem, 1, 'systeemrij, net als manual: de app leunt erop');
assert.equal(p.baseUrl, '', 'geen URL, want er gaat geen verzoek uit');
assert.equal(one(`SELECT fiatPrice FROM ProviderFixedRate WHERE providerId = ?`, [p.id]).fiatPrice, 1);
assert.deepEqual(all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [p.id]), [{ symbol: 'EUR' }]);
assert.deepEqual(
all(`SELECT capability, pathTemplate FROM ProviderEndpoint WHERE providerId = ? ORDER BY capability`, [p.id]),
[{ capability: 'history_point', pathTemplate: '' }, { capability: 'spot', pathTemplate: '' }]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('zet het EUR-asset neer in centen, en laat de bestaande assets staan', () => {
opzet70();
runMigrations();
assert.deepEqual(all(`SELECT symbol, kind, unit, subunit, decimals, displayUnit FROM Asset ORDER BY symbol`),
[{ symbol: 'BTC', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'subunit' },
{ symbol: 'EUR', kind: 'fiat', unit: 'EUR', subunit: 'cent', decimals: 2, displayUnit: 'unit' },
{ symbol: 'USD', kind: 'fiat', unit: 'USD', subunit: 'cent', decimals: 2, displayUnit: 'unit' }]);
});
it('vult de bronkeuze voor de spot en de historie', () => {
opzet70();
runMigrations();
assert.deepEqual(bronkeuze(), [
{ capability: 'history', bron: 'fixed', rank: 1, status: 'confirmed' },
{ capability: 'spot', bron: 'fixed', rank: 1, status: 'confirmed' },
]);
});
it('laat een bestaand EUR-asset staan in plaats van er een tweede naast te zetten', () => {
opzet70();
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, displayUnit, decimals, kind)
VALUES (5,'EUR','Euro (eigen)','euro (eigen)','EUR','unit',4,'fiat')`);
assert.doesNotThrow(() => runMigrations());
assert.deepEqual(all(`SELECT id, name, decimals FROM Asset WHERE symbol = 'EUR'`),
[{ id: 5, name: 'Euro (eigen)', decimals: 4 }]);
assert.equal(bronkeuze().length, 2, 'de keten hangt aan het bestaande asset');
});
it('laat een bronkeuze die de gebruiker zelf heeft gemaakt ongemoeid', () => {
opzet70();
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, displayUnit, decimals, kind)
VALUES (5,'EUR','Euro','euro','EUR','unit',2,'fiat')`);
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES ('eigen','Eigen bron','eigen bron','https://x','none',0,1,0,0,'2026-08-20T10:00:00.000Z')`);
const eigen = one(`SELECT id FROM Provider WHERE key = 'eigen'`).id;
run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status)
VALUES ('tax', 5, 'spot', ?, 1, 'manual')`, [eigen]);
runMigrations();
assert.deepEqual(bronkeuze(), [
{ capability: 'history', bron: 'fixed', rank: 1, status: 'confirmed' },
{ capability: 'spot', bron: 'eigen', rank: 1, status: 'manual' },
{ capability: 'spot', bron: 'fixed', rank: 2, status: 'confirmed' },
]);
});
it('weigert een vaste koers die geen koers is', () => {
opzet70();
runMigrations();
const p = provider();
assert.throws(() => run(`UPDATE ProviderFixedRate SET fiatPrice = 0 WHERE providerId = ?`, [p.id]), /CHECK/i);
assert.throws(() => run(`UPDATE ProviderFixedRate SET fiatPrice = -1 WHERE providerId = ?`, [p.id]), /CHECK/i);
});
it('laat een provider die al fixed heet ongemoeid in plaats van er een tweede naast te zetten', () => {
opzet70();
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES ('fixed','Iets anders','iets anders','https://x','none',0,1,0,0,'2026-08-20T10:00:00.000Z')`);
assert.doesNotThrow(() => runMigrations());
assert.deepEqual(all(`SELECT name FROM Provider WHERE key = 'fixed'`), [{ name: 'Iets anders' }]);
assert.deepEqual(all(`SELECT fiatPrice FROM ProviderFixedRate`), []);
});
});
describe('meting: wat een RENAME doet met de foreign keys van ándere tabellen', () => {
const opzetTwee = () => {
openDatabase(':memory:');
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE Event (id INTEGER PRIMARY KEY, assetId INTEGER REFERENCES Asset(id)) STRICT;`);
};
const eventSql = () => all(`SELECT sql FROM sqlite_master WHERE name = 'Event'`)[0].sql;
it('node:sqlite honoreert PRAGMA foreign_keys = OFF, ondanks de optie bij het openen', () => {
opzetTwee();
assert.equal(all(`PRAGMA foreign_keys`)[0].foreign_keys, 1, 'aan bij het openen');
run(`PRAGMA foreign_keys = OFF`);
assert.equal(all(`PRAGMA foreign_keys`)[0].foreign_keys, 0, 'en hij gaat echt uit');
});
it('herschrijft de REFERENCES-clausule TOCH, met foreign_keys uit', () => {
opzetTwee();
run(`PRAGMA foreign_keys = OFF`);
run(`ALTER TABLE Asset RENAME TO AssetOud`);
assert.match(eventSql(), /REFERENCES "AssetOud"/,
'de FK van Event is meeverhuisd naar de tijdelijke naam; na een DROP verwijst hij naar niets');
});
it('laat de clausule staan met legacy_alter_table aan, en dát is de knop die werkt', () => {
opzetTwee();
run(`PRAGMA foreign_keys = OFF`);
run(`PRAGMA legacy_alter_table = ON`);
run(`ALTER TABLE Asset RENAME TO AssetOud`);
assert.match(eventSql(), /REFERENCES Asset\(id\)/, 'Event blijft naar Asset wijzen');
assert.equal(/AssetOud/.test(eventSql()), false);
});
it('is per verbinding en niet blijvend: legacy_alter_table gaat weer uit', () => {
opzetTwee();
run(`PRAGMA legacy_alter_table = ON`);
run(`PRAGMA legacy_alter_table = OFF`);
run(`ALTER TABLE Asset RENAME TO AssetOud`);
assert.match(eventSql(), /AssetOud/, 'weer het moderne gedrag');
});
});
describe('migraties: de keten levert dezelfde tabellen op als de DDL', () => {
const ddl = (q, namen) => q(`SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY name`)
.filter(r => r.sql && namen.includes(r.tbl_name))
.map(r => `${r.type} ${r.name}: ${String(r.sql)
.replace(/--[^\n]*/g, '').replace(/\s+/g, ' ')
.replace(/\s+([,)])/g, '$1').replace(/\(\s+/g, '(').trim()}`);
const gemigreerdeTabellen = () =>
all(`SELECT name FROM sqlite_master WHERE type = 'table'
AND name NOT IN ('Schema', 'Preference', 'MetalProduct', 'DashboardWidget',
'Provider', 'ProviderCoverage')
ORDER BY name`)
.map(r => r.name);
it('migreert een database van 60 naar de huidige versie en maakt de nieuwe tabellen aan', () => {
opzet({ versie: 60 });
assert.equal(all(`SELECT name FROM sqlite_master WHERE name = 'AiToolCall'`).length, 0, 'nog niet');
assert.equal(runMigrations(), true, 'er is gemigreerd, dus er valt iets te persisteren');
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.deepEqual(gemigreerdeTabellen(),
['Account', 'AiProposal', 'AiToolCall', 'AiToolCallMessage', 'Asset', 'AssetNote', 'BusSignal', 'ChangeLog',
'Command', 'Event', 'HygieneAcceptance', 'IgnoredPriceGap', 'IntegrationCall', 'InterestPeriod', 'InterestSchedule', 'Notification',
'Portfolio', 'PriceAbsence', 'PriceSource', 'ProviderCallLimit', 'ProviderEndpoint', 'ProviderFixedRate', 'ProviderOutputMap', 'Valuation']);
});
it('geeft élke gemigreerde tabel exact dezelfde DDL als een verse database', () => {
opzet({ versie: 60 });
runMigrations();
const namen = gemigreerdeTabellen();
const gemigreerd = ddl(all, namen);
const vers = openBaselineDb();
try {
assert.deepEqual(gemigreerd, ddl((sql) => vers.prepare(sql).all(), namen),
'elke tabel plus zijn indexen, inclusief types, NOT NULL en élke CHECK');
assert.equal(gemigreerd.length, 62, 'vierentwintig tabellen, zesendertig indexen en twee triggers');
} finally { vers.close(); }
});
it('houdt de foreign keys van andere tabellen op de échte tabelnaam (migratie 68)', () => {
opzet({ versie: 60 });
runMigrations();
const sql = all(`SELECT sql FROM sqlite_master WHERE type='table' AND name='Event'`)[0].sql;
assert.match(sql, /REFERENCES Asset\(id\)/, 'de FK hoort naar Asset te wijzen');
assert.equal(/Oud/.test(sql), false, 'en nergens naar een tijdelijke naam');
assert.deepEqual(all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%EventOud%'`), []);
assert.deepEqual(all(`PRAGMA foreign_key_check`), [], 'en de bestaande rijen hangen nog aan hun ouder');
});
it('zet de pragma na de herbouw terug op aan (migratie 68)', () => {
opzet({ versie: 60 });
runMigrations();
assert.equal(all(`PRAGMA foreign_keys`)[0].foreign_keys, 1);
});
it('neemt de bestaande rijen mee bij de herbouw (migratie 68)', () => {
opzet({ versie: 60 });
zetRijen({ decimals: 8, eurPrice: 30000, eurFee: 12.5, unitFee: 250 });
runMigrations();
assert.deepEqual(all(`SELECT id, symbol, decimals FROM Asset`),
[{ id: 1, symbol: 'BTC', decimals: 8 }, { id: 2, symbol: 'EUR', decimals: 2 }, { id: 3, symbol: 'USD', decimals: 2 }]);
assert.deepEqual(all(`SELECT id, assetId, amount, fiatPrice, fiatFee, unitFee FROM Event`),
[{ id: 1, assetId: 1, amount: 100000, fiatPrice: 30000, fiatFee: 12.5, unitFee: 250 }]);
});
it('handhaaft de nieuwe CHECKs ook in de gemigreerde tabellen (migratie 68)', () => {
opzet({ versie: 60 });
zetRijen();
runMigrations();
assert.throws(() => run(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/i);
assert.throws(() => run(`UPDATE Event SET unitFee = -1 WHERE id = 1`), /CHECK/i);
assert.throws(() => run(`UPDATE Event SET fiatPrice = -1 WHERE id = 1`), /CHECK/i);
assert.throws(() => run(`UPDATE Event SET fiatFee = -1 WHERE id = 1`), /CHECK/i);
});
it('weigert te migreren als een bestaande asset te veel decimalen heeft, en laat de database staan', () => {
opzet({ versie: 60 });
zetRijen({ decimals: 18 });
assert.throws(() => runMigrations(), /1 asset/);
assert.throws(() => runMigrations(), /decimals/);
assert.equal(readVersion(), 67, 'de versie is niet opgeschoven');
assert.equal(all(`SELECT COUNT(*) AS n FROM Asset`)[0].n, 1);
assert.equal(/decimals <= 9/.test(all(`SELECT sql FROM sqlite_master WHERE name='Asset'`)[0].sql), false,
'de tabel is niet half herbouwd');
});
it('weigert te migreren als een bestaande boeking een negatief geldbedrag heeft', () => {
opzet({ versie: 60 });
zetRijen({ unitFee: -50000 });
assert.throws(() => runMigrations(), /1 event/);
assert.throws(() => runMigrations(), /negative/);
assert.equal(readVersion(), 67);
assert.equal(all(`SELECT unitFee FROM Event`)[0].unitFee, -50000, 'de rij staat er nog, onaangeroerd');
});
it('handhaaft de CHECK op outcome ook in de gemigreerde tabel', () => {
opzet({ versie: 60 });
runMigrations();
assert.throws(
() => run(`INSERT INTO AiToolCall (sessionId, tool, outcome, occurredAt) VALUES ('s','t','geslaagd','${'2026-08-14T12:00:00.000Z'}')`),
/CHECK/i);
});
});
describe('migratie 72: taxCategory op Asset en Account', () => {
function opzet71() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (71)`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL,
description TEXT, unit TEXT NOT NULL, subunit TEXT,
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
accountTabelVoor72();
dashboardWidgetStomp();
valuationTabelVoor74();
interestScheduleTabelVoor75();
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind)
VALUES (1,'BTC','Bitcoin','bitcoin','BTC','sat','subunit',8,'crypto')`);
run(`INSERT INTO Account (id, name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES (1,'Kluis','kluis','vault',1,0,1,NULL)`);
}
const kolommen = (tabel) => all(`SELECT name FROM pragma_table_info(?)`, [tabel]).map(r => r.name);
it('zet de kolom op beide tabellen, achter de bestaande kolommen', () => {
opzet71();
runMigrations();
assert.ok(kolommen('Asset').includes('taxCategory'));
assert.ok(kolommen('Account').includes('taxCategory'));
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('laat de kolom leeg voor bestaande rijen, want NULL betekent hier iets', () => {
opzet71();
runMigrations();
assert.equal(one(`SELECT taxCategory AS c FROM Asset WHERE id = 1`).c, null);
assert.equal(one(`SELECT taxCategory AS c FROM Account WHERE id = 1`).c, null);
});
it('neemt de bestaande rijen mee en zet beide indexen terug', () => {
opzet71();
runMigrations();
assert.deepEqual(all(`SELECT id, symbol, kind FROM Asset`), [{ id: 1, symbol: 'BTC', kind: 'crypto' }]);
assert.deepEqual(all(`SELECT id, name, kind FROM Account`), [{ id: 1, name: 'Kluis', kind: 'vault' }]);
assert.deepEqual(
all(`SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name`),
[{ name: 'IxAccountSearchName' }, { name: 'IxAssetSearchName' },
{ name: 'IxChangeLogOccurredAt' }, { name: 'IxChangeLogTableNameRowId' },
{ name: 'IxHygieneAcceptanceKind' }, { name: 'IxIgnoredPriceGapAssetId' },
{ name: 'IxIntegrationCallOccurredAt' },
{ name: 'IxInterestScheduleAccountId' }, { name: 'IxInterestSchedulePayoutAssetId' },
{ name: 'IxInterestSchedulePrincipalAssetId' }, { name: 'IxPriceAbsenceProviderId' }, { name: 'IxValuationAccountId' },
{ name: 'UxAssetNoteAssetId' }, { name: 'UxHygieneAcceptanceFingerprint' }, { name: 'UxPriceAbsenceAssetIdProviderIdDay' }]);
});
it('handhaaft de nieuwe CHECK op beide tabellen', () => {
opzet71();
runMigrations();
assert.throws(() => run(`UPDATE Asset SET taxCategory = 'onzin' WHERE id = 1`), /CHECK/i);
assert.throws(() => run(`UPDATE Account SET taxCategory = 'onzin' WHERE id = 1`), /CHECK/i);
assert.doesNotThrow(() => run(`UPDATE Account SET taxCategory = 'bank' WHERE id = 1`));
});
it('houdt de andere CHECKs van beide tabellen overeind', () => {
opzet71();
runMigrations();
assert.throws(() => run(`UPDATE Asset SET decimals = 18 WHERE id = 1`), /CHECK/i);
assert.throws(() => run(`UPDATE Asset SET kind = 'onzin' WHERE id = 1`), /CHECK/i);
assert.throws(() => run(`UPDATE Account SET isActive = 0 WHERE id = 1`), /CHECK/i,
'isActive 0 zonder closedAt mag niet');
assert.throws(() => run(`UPDATE Account SET kind = 'onzin' WHERE id = 1`), /CHECK/i);
});
});
describe('migratie 73', () => {
function opzet72() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (72)`);
dashboardWidgetStomp();
valuationTabelVoor74();
interestScheduleTabelVoor75();
run(`INSERT INTO DashboardWidget (id, profileId, type) VALUES (1, 1, 'dbstatus')`);
run(`INSERT INTO DashboardWidget (id, profileId, type) VALUES (2, 1, 'standing')`);
run(`INSERT INTO DashboardWidget (id, profileId, type) VALUES (3, 2, 'dbstatus')`);
}
it('verwijdert dat widget uit élk profiel en laat de rest staan', () => {
opzet72();
runMigrations();
assert.deepEqual(all(`SELECT id, profileId, type FROM DashboardWidget ORDER BY id`),
[{ id: 2, profileId: 1, type: 'standing' }]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('doet niets bij een dashboard dat het widget niet had', () => {
opzet72();
run(`DELETE FROM DashboardWidget WHERE type = 'dbstatus'`);
runMigrations();
assert.deepEqual(all(`SELECT id FROM DashboardWidget`), [{ id: 2 }]);
});
});
describe('migratie 77: ai.enabled wordt ai.allow_read', () => {
afterEach(() => closeDatabase());
function opzet76({ enabled, write = null }) {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (76)`);
run(`CREATE TABLE Preference (
scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)
) STRICT;`);
if (enabled !== null) {
run(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'enabled', ?)`, [JSON.stringify(enabled)]);
}
if (write !== null) {
run(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'allow_write', ?)`, [JSON.stringify(write)]);
}
}
const voorkeur = (key) => one(`SELECT value AS v FROM Preference WHERE scope = 'ai' AND key = ?`, [key]);
it('hernoemt de sleutel en laat de waarde staan', () => {
opzet76({ enabled: 1 });
runMigrations();
assert.equal(voorkeur('enabled'), null, 'de oude sleutel hoort weg te zijn');
assert.equal(voorkeur('allow_read').v, '1');
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('zet bij enabled = 0 ALLE DRIE de schuiven uit, en niet alleen het lezen', () => {
opzet76({ enabled: 0, write: 1 });
runMigrations();
assert.equal(voorkeur('allow_read').v, '0');
assert.equal(voorkeur('allow_write').v, '0', 'anders is het schrijfrecht stilletjes terug');
assert.equal(voorkeur('allow_control').v, '0');
});
it('laat bij enabled = 1 de andere schuiven met rust', () => {
opzet76({ enabled: 1, write: 1 });
runMigrations();
assert.equal(voorkeur('allow_read').v, '1');
assert.equal(voorkeur('allow_write').v, '1');
assert.equal(voorkeur('allow_control'), null, 'geen rij nodig: de registry-default is uit');
});
it('doet niets bij een database zonder die voorkeur, en valt daar niet over', () => {
opzet76({ enabled: null });
runMigrations();
assert.equal(voorkeur('allow_read'), null);
assert.equal(voorkeur('allow_raw'), null);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
});
describe('migratie 86: PriceHistory herbouwen zonder een rij of een waarde kwijt te raken', () => {
function opzet85() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (85)`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL) STRICT;`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;`);
run(`INSERT INTO Asset (id, symbol) VALUES (1, 'BTC')`);
run(`INSERT INTO Provider (id, name) VALUES (1, 'CoinGecko')`);
run(`CREATE TABLE PriceHistory (
id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
eur REAL,
usd REAL,
resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')),
observedAt TEXT NOT NULL,
updatedAt TEXT,
UNIQUE (assetId, providerId, observedAt, resolution),
CHECK (eur IS NOT NULL OR usd IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`);
run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`);
run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt)
VALUES (1, 1, 1, 67000.5, 72000.25, 'day', '2026-09-01T00:00:00Z', '2026-09-02T10:00:00Z')`);
run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt)
VALUES (2, 1, 1, NULL, 71000.0, 'timestamp', '2026-09-02T12:34:56Z', NULL)`);
run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt)
VALUES (3, 1, 1, 66000.0, NULL, 'day', '2026-09-03T00:00:00Z', NULL)`);
}
it('houdt elke rij en elke waarde vast', () => {
opzet85();
const voor = all(`SELECT id, assetId, providerId, eur, resolution, observedAt, updatedAt
FROM PriceHistory WHERE eur IS NOT NULL ORDER BY id`);
runMigrations();
const na = all(`SELECT id, assetId, providerId, fiatPrice AS eur, resolution, observedAt, updatedAt
FROM PriceHistory ORDER BY id`);
assert.deepEqual(na, voor, 'de herbouw hoort elke kolom over te zetten, ook de NULL-bare');
});
it('zet createdAt erbij en laat hem leeg', () => {
opzet85();
runMigrations();
const rijen = all(`SELECT createdAt FROM PriceHistory`);
assert.equal(rijen.length, 2, 'drie rijen, min die met alleen een dollarkoers (migratie 107)');
assert.ok(rijen.every(r => r.createdAt === null),
'een migratiedatum invullen zou een datum opleveren die bruikbaar oogt en onwaar is');
});
it('zet de twee indexen terug', () => {
opzet85();
runMigrations();
const namen = all(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'PriceHistory'
AND name NOT LIKE 'sqlite_%' ORDER BY name`).map(r => r.name);
assert.deepEqual(namen, ['IxPriceHistoryAssetIdObservedAt', 'IxPriceHistoryProviderId']);
});
it('draait niet nog een keer als de kolom er al staat', () => {
opzet85();
runMigrations();
assert.equal(runMigrations(), false, 'de keten staat op de huidige versie en heeft niets te doen');
assert.equal(all(`SELECT id FROM PriceHistory`).length, 2);
});
});
describe('migratie 90: leveringsvaluta en een transform voor datum-sleutels', () => {
function opzet89() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (89)`);
run(`CREATE TABLE Provider (
id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL,
baseUrl TEXT NOT NULL, authenticationHeaderName TEXT, authenticationParameterName TEXT,
authenticationKind TEXT NOT NULL, authenticationRequired INTEGER NOT NULL,
isActive INTEGER NOT NULL, isSeed INTEGER NOT NULL, isSystem INTEGER NOT NULL,
createdAt TEXT NOT NULL, UNIQUE (key)
) STRICT;`);
run(`INSERT INTO Provider (id, key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES (1, 'goldapi', 'GoldAPI', 'goldapi', 'https://www.goldapi.io', 'header_key', 1, 0, 1, 0, '2026-01-01T00:00:00Z')`);
run(`CREATE TABLE ProviderCoverage (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, symbol TEXT NOT NULL) STRICT;`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL,
description TEXT, unit TEXT NOT NULL, subunit TEXT, displayUnit TEXT NOT NULL,
decimals INTEGER NOT NULL, kind TEXT NOT NULL
) STRICT;`);
run(`CREATE TABLE ConsumerSource (
id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL,
consumerKey TEXT NOT NULL, capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL
) STRICT;`);
run(`CREATE TABLE ProviderEndpoint (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
pathTemplate TEXT NOT NULL,
queryTemplate TEXT,
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
method TEXT NOT NULL CHECK (method IN ('GET','POST')),
createdAt TEXT,
UNIQUE (providerId, capability)
) STRICT;`);
run(`CREATE TABLE ProviderOutputMap (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
jsonPath TEXT NOT NULL,
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')),
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram')),
createdAt TEXT
) STRICT;`);
run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`);
run(`INSERT INTO ProviderEndpoint (id, providerId, pathTemplate, queryTemplate, capability, method, createdAt)
VALUES (4, 1, '/api/{symbol}/EUR', 'a=1', 'spot', 'GET', '2026-09-01T10:00:00Z')`);
run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform, createdAt)
VALUES (9, 1, 'price', 'spot', 'EUR', 'troy_ounce_to_gram', '2026-09-01T10:00:00Z')`);
}
it('neemt elke kolom van beide tabellen mee en laat quoteCurrency leeg', () => {
opzet89();
runMigrations();
assert.deepEqual({ ...one(`SELECT * FROM ProviderEndpoint WHERE id = 4`) }, {
id: 4, providerId: 1, pathTemplate: '/api/{symbol}/EUR', queryTemplate: 'a=1', capability: 'spot',
method: 'GET', quoteCurrency: null, minIntervalSeconds: null, createdAt: '2026-09-01T10:00:00Z',
});
assert.deepEqual({ ...one(`SELECT * FROM ProviderOutputMap WHERE id = 9`) }, {
id: 9, providerId: 1, jsonPath: 'price', capability: 'spot', internalField: 'price',
transform: 'troy_ounce_to_gram', createdAt: '2026-09-01T10:00:00Z',
});
});
it('laat de nieuwe transform toe, weigert een verzonnen, en zet de index terug', () => {
opzet89();
runMigrations();
run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform)
VALUES (1, 'rates{}.@key', 'daily_history', 'time', 'iso_date_to_s')`);
assert.throws(() => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform)
VALUES (1, 'x', 'spot', 'price', 'verzonnen')`), /CHECK/);
assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = 'IxProviderOutputMapProviderIdCapability'`));
});
});
describe('migratie 96: een minimum-interval per endpoint', () => {
function opzet95() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (95)`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL) STRICT;`);
run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool')`);
run(`CREATE TABLE ProviderEndpoint (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
pathTemplate TEXT NOT NULL,
queryTemplate TEXT,
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
method TEXT NOT NULL CHECK (method IN ('GET','POST')),
quoteCurrency TEXT,
createdAt TEXT,
UNIQUE (providerId, capability)
) STRICT;`);
run(`INSERT INTO ProviderEndpoint (id, providerId, pathTemplate, queryTemplate, capability, method, quoteCurrency, createdAt)
VALUES (7, 1, '/api/1/x', NULL, 'daily_history', 'GET', 'USD', '2026-09-24T10:00:00Z')`);
}
it('neemt elke kolom mee, ook de leveringsvaluta, en laat het interval leeg', () => {
opzet95();
runMigrations();
assert.deepEqual({ ...one(`SELECT * FROM ProviderEndpoint WHERE id = 7`) }, {
id: 7, providerId: 1, pathTemplate: '/api/1/x', queryTemplate: null, capability: 'daily_history',
method: 'GET', quoteCurrency: 'USD', minIntervalSeconds: null, createdAt: '2026-09-24T10:00:00Z',
});
assert.ok(!one(`SELECT name FROM sqlite_master WHERE name = 'ProviderEndpointOud'`), 'de tijdelijke tabel is weg');
});
it('weigert een interval van nul in de database zelf', () => {
opzet95();
runMigrations();
assert.throws(() => run(`UPDATE ProviderEndpoint SET minIntervalSeconds = 0 WHERE id = 7`), /CHECK/);
run(`UPDATE ProviderEndpoint SET minIntervalSeconds = 600 WHERE id = 7`);
assert.equal(one(`SELECT minIntervalSeconds AS s FROM ProviderEndpoint`).s, 600);
});
});
describe('migratie 97: één bronkeuze per asset, en historie voor reeks en punt samen', () => {
function opzet96() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (96)`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL) STRICT;`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL) STRICT;`);
run(`INSERT INTO Asset (id, symbol) VALUES (1, 'XAU'), (2, 'ETH')`);
run(`INSERT INTO Provider (id, key) VALUES (1, 'reeks'), (2, 'perdatum'), (3, 'spot'), (4, 'tweede')`);
consumerSourceVoor97();
}
const cel = (consumerKey, assetId, capability, providerId, rank, status, createdAt = null) =>
run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`, [consumerKey, assetId, capability, providerId, rank, status, createdAt]);
const keuzes = (assetId = 1) => all(`SELECT capability, providerId, rank, status FROM PriceSource
WHERE assetId = ? ORDER BY capability, rank IS NULL, rank, providerId`, [assetId]);
it('neemt per bron de beste rang over alle consumenten en nummert opnieuw', () => {
opzet96();
cel('fiat', 1, 'spot', 3, 1, 'confirmed');
cel('fiat', 1, 'spot', 4, 2, 'confirmed');
cel('tax', 1, 'spot', 4, 1, 'confirmed');
cel('converter', 1, 'spot', 3, 2, 'confirmed');
runMigrations();
assert.deepEqual(keuzes(), [
{ capability: 'spot', providerId: 3, rank: 1, status: 'confirmed' },
{ capability: 'spot', providerId: 4, rank: 2, status: 'confirmed' },
], 'gelijke beste rang: de laagste id gaat voor, zodat de uitkomst niet van de volgorde van de rijen afhangt');
});
it('zet onder historie de reeks voor de bron per datum, ook als die een betere rang had', () => {
opzet96();
cel('tax', 1, 'history_point', 2, 1, 'confirmed');
cel('tax', 1, 'daily_history', 1, 2, 'confirmed');
runMigrations();
assert.deepEqual(keuzes(), [
{ capability: 'history', providerId: 1, rank: 1, status: 'confirmed' },
{ capability: 'history', providerId: 2, rank: 2, status: 'confirmed' },
]);
});
it('een handmatige keuze wint bij gelijke rang, en de sterkste status blijft', () => {
opzet96();
cel('fiat', 1, 'spot', 3, 1, 'proposed');
cel('tax', 1, 'spot', 3, 1, 'confirmed');
cel('tax', 1, 'history_point', 2, 1, 'confirmed');
cel('fiat', 1, 'history_point', 4, 1, 'manual');
runMigrations();
assert.deepEqual(keuzes(), [
{ capability: 'history', providerId: 4, rank: 1, status: 'manual' },
{ capability: 'history', providerId: 2, rank: 2, status: 'confirmed' },
{ capability: 'spot', providerId: 3, rank: 1, status: 'confirmed' },
]);
});
it('een bron die alleen uitgesloten staat, blijft uitgesloten en telt niet mee in de rangen', () => {
opzet96();
cel('tax', 1, 'spot', 4, null, 'manual');
cel('fiat', 1, 'spot', 3, 2, 'confirmed');
runMigrations();
assert.deepEqual(keuzes(), [
{ capability: 'spot', providerId: 3, rank: 1, status: 'confirmed' },
{ capability: 'spot', providerId: 4, rank: null, status: 'manual' },
]);
});
it('houdt assets apart, bewaart de oudste createdAt, en ruimt ConsumerSource op', () => {
opzet96();
cel('fiat', 1, 'spot', 3, 1, 'confirmed', '2026-05-01T00:00:00Z');
cel('tax', 1, 'spot', 3, 1, 'confirmed', '2026-03-01T00:00:00Z');
cel('tax', 2, 'spot', 4, 1, 'confirmed');
runMigrations();
assert.equal(one(`SELECT createdAt FROM PriceSource WHERE assetId = 1`).createdAt, '2026-03-01T00:00:00Z');
assert.deepEqual(keuzes(2), [{ capability: 'spot', providerId: 4, rank: 1, status: 'confirmed' }]);
assert.equal(one(`SELECT name FROM sqlite_master WHERE name = 'ConsumerSource'`), null);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('start ook als een andere tabel ConsumerSource in een commentaar noemt', () => {
opzet96();
run(`CREATE TABLE DashboardWidget (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL -- no CHECK, the precedent is ConsumerSource.consumerKey
) STRICT;`);
cel('tax', 1, 'spot', 3, 1, 'confirmed');
assert.doesNotThrow(() => runMigrations());
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('weigert wel als een tabel echt naar ConsumerSource verwijst, en laat dan alles staan', () => {
opzet96();
run(`CREATE TABLE Verwijzer (id INTEGER PRIMARY KEY, bron INTEGER REFERENCES ConsumerSource(id)) STRICT;`);
cel('tax', 1, 'spot', 3, 1, 'confirmed');
assert.throws(() => runMigrations(), /Migration 97 aborted: Verwijzer/);
assert.ok(one(`SELECT name FROM sqlite_master WHERE name = 'ConsumerSource'`), 'teruggedraaid');
assert.equal(one(`SELECT name FROM sqlite_master WHERE name = 'PriceSource'`), null);
});
it('weigert in de nieuwe tabel een endpoint-capability als keuze', () => {
opzet96();
runMigrations();
assert.throws(() => run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
VALUES (1, 'daily_history', 1, 1, 'manual')`), /CHECK/);
});
});
describe('migratie 98: de voorstelsoort source_choice', () => {
function opzet97() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (97)`);
for (const t of ['Asset', 'Account', 'Event', 'Valuation']) run(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY) STRICT;`);
run(`INSERT INTO Asset (id) VALUES (1)`);
run(`INSERT INTO Event (id) VALUES (7)`);
run(`CREATE TABLE AiProposal (
id INTEGER PRIMARY KEY, assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
accountId INTEGER, fromAccountId INTEGER, toAccountId INTEGER,
createdEventId INTEGER, createdAccountId INTEGER, createdValuationId INTEGER,
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference')),
name TEXT, accountKind TEXT, isOwnCustody INTEGER, eventType TEXT, amount INTEGER, eur REAL, eurFee REAL,
balance INTEGER, observedAt TEXT, scope TEXT, key TEXT, value TEXT, note TEXT,
status TEXT NOT NULL, detail TEXT, resolvedAt TEXT, createdAt TEXT NOT NULL,
CHECK (kind = 'preference' OR (scope IS NULL AND key IS NULL AND value IS NULL))
) STRICT;`);
run(`INSERT INTO AiProposal (id, kind, scope, key, value, status, createdAt)
VALUES (3, 'preference', 'app', 'currency', '"usd"', 'pending', '2026-09-05T00:00:00Z')`);
run(`INSERT INTO AiProposal (id, assetId, createdEventId, kind, eventType, amount, observedAt, toAccountId, status, resolvedAt, createdAt)
VALUES (4, 1, 7, 'event', 'interest', 5, '2026-01-01T00:00:00Z', 1, 'accepted', '2026-09-06T00:00:00Z', '2026-09-05T00:00:00Z')`);
}
it('neemt elke kolom mee, ook de herkomst en de voorkeurvelden', () => {
opzet97();
runMigrations();
assert.deepEqual({ ...one(`SELECT scope, key, value FROM AiProposal WHERE id = 3`) },
{ scope: 'app', key: 'currency', value: '"usd"' });
assert.deepEqual({ ...one(`SELECT createdEventId, resolvedAt FROM AiProposal WHERE id = 4`) },
{ createdEventId: 7, resolvedAt: '2026-09-06T00:00:00Z' });
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('laat een bronkeuze toe, en weigert er een met een onbekende actie of zonder bron', () => {
opzet97();
runMigrations();
run(`INSERT INTO AiProposal (assetId, kind, scope, key, value, status, createdAt)
VALUES (1, 'source_choice', 'history', 'eulerpool', 'first', 'pending', '2026-09-25T00:00:00Z')`);
assert.throws(() => run(`INSERT INTO AiProposal (assetId, kind, scope, key, value, status, createdAt)
VALUES (1, 'source_choice', 'history', 'eulerpool', 'bovenaan', 'pending', '2026-09-25T00:00:00Z')`), /CHECK/);
assert.throws(() => run(`INSERT INTO AiProposal (assetId, kind, scope, key, value, status, createdAt)
VALUES (1, 'source_choice', 'daily_history', 'eulerpool', 'first', 'pending', '2026-09-25T00:00:00Z')`), /CHECK/);
assert.throws(() => run(`INSERT INTO AiProposal (kind, name, scope, status, createdAt)
VALUES ('portfolio', 'Demo', 'history', 'pending', '2026-09-25T00:00:00Z')`), /CHECK/,
'een andere soort draagt die velden nog steeds niet');
});
});
describe('migratie 99: Eulerpool als één bron', () => {
function opzet98({ beide = true } = {}) {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (98)`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE) STRICT;`);
run(`CREATE TABLE ProviderCoverage (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, symbol TEXT NOT NULL, UNIQUE (providerId, symbol)) STRICT;`);
run(`CREATE TABLE ProviderEndpoint (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, capability TEXT NOT NULL, pathTemplate TEXT NOT NULL) STRICT;`);
run(`CREATE TABLE ProviderInputMap (
id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
symbol TEXT NOT NULL, externalId TEXT NOT NULL, createdAt TEXT, UNIQUE (providerId, symbol)) STRICT;`);
for (const t of ['ProviderOutputMap', 'ProviderCallLimit', 'ProviderAuthenticationHmac', 'ProviderFixedRate']) {
run(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL) STRICT;`);
}
run(`CREATE TABLE PriceSource (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL,
capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL,
UNIQUE (assetId, capability, providerId), UNIQUE (assetId, capability, rank)) STRICT;`);
run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL,
observedAt TEXT NOT NULL, resolution TEXT NOT NULL, eur REAL, UNIQUE (assetId, providerId, observedAt, resolution)) STRICT;`);
run(`CREATE TABLE ApiCallMetric (providerId INTEGER NOT NULL, capability TEXT NOT NULL, bucketMinute TEXT NOT NULL,
okCount INTEGER NOT NULL, failCount INTEGER NOT NULL, sumLatencyMs INTEGER NOT NULL, maxLatencyMs INTEGER NOT NULL,
PRIMARY KEY (providerId, capability, bucketMinute)) STRICT;`);
if (beide) {
run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool')`);
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (1, 'BTC')`);
run(`INSERT INTO ProviderEndpoint (providerId, capability, pathTemplate) VALUES (1, 'daily_history', '/api/1/crypto/quotes/{symbol}')`);
run(`INSERT INTO ApiCallMetric VALUES (1, 'daily_history', '2026-09-25T10:00', 3, 1, 300, 150)`);
}
run(`INSERT INTO Provider (id, key) VALUES (2, 'eulerpool_metal')`);
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (2, 'XAU')`);
run(`INSERT INTO ProviderEndpoint (providerId, capability, pathTemplate) VALUES (2, 'daily_history', '/api/1/commodity/quotes/{symbol}')`);
run(`INSERT INTO ProviderCallLimit (providerId) VALUES (2)`);
run(`INSERT INTO PriceSource (assetId, providerId, capability, rank, status) VALUES (9, 2, 'history', 1, 'manual')`);
run(`INSERT INTO PriceHistory (assetId, providerId, observedAt, resolution, eur) VALUES (9, 2, '2026-01-01T00:00:00Z', 'day', 2.3)`);
run(`INSERT INTO ApiCallMetric VALUES (2, 'daily_history', '2026-09-25T10:00', 2, 0, 100, 90)`);
}
it('voegt de metaalrij samen in de cryptorij, met alles wat eraan hing', () => {
opzet98();
runMigrations();
assert.equal(one(`SELECT id FROM Provider WHERE key = 'eulerpool_metal'`), null, 'de metaalrij is weg');
assert.equal(one(`SELECT pathTemplate AS p FROM ProviderEndpoint WHERE providerId = 1`).p, '/api/1/{external_id}/quotes/{symbol}');
assert.deepEqual(all(`SELECT symbol, externalId, quoteUnit FROM ProviderInputMap WHERE providerId = 1 ORDER BY symbol`).map(r => ({ ...r })),
[{ symbol: null, externalId: 'crypto', quoteUnit: null }, { symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt' }]);
assert.deepEqual(all(`SELECT symbol FROM ProviderCoverage WHERE providerId = 1 ORDER BY symbol`).map(r => r.symbol), ['BTC', 'XAU']);
assert.deepEqual({ ...one(`SELECT providerId, rank, status FROM PriceSource WHERE assetId = 9`) }, { providerId: 1, rank: 1, status: 'manual' });
assert.equal(one(`SELECT providerId FROM PriceHistory WHERE assetId = 9`).providerId, 1);
assert.deepEqual({ ...one(`SELECT okCount, failCount, sumLatencyMs, maxLatencyMs FROM ApiCallMetric WHERE providerId = 1`) },
{ okCount: 5, failCount: 1, sumLatencyMs: 400, maxLatencyMs: 150 });
for (const t of ['ProviderEndpoint', 'ProviderCoverage', 'ProviderCallLimit', 'ApiCallMetric', 'PriceSource', 'PriceHistory']) {
assert.equal(one(`SELECT COUNT(*) AS n FROM ${t} WHERE providerId = 2`).n, 0, `${t} houdt een rij van de metaalbron`);
}
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('laat alles staan als alleen de metaalrij er is, maar zet wel de kolom neer', () => {
opzet98({ beide: false });
runMigrations();
assert.ok(one(`SELECT id FROM Provider WHERE key = 'eulerpool_metal'`));
assert.equal(one(`SELECT pathTemplate AS p FROM ProviderEndpoint WHERE providerId = 2`).p, '/api/1/commodity/quotes/{symbol}');
run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (2, 'XAU', 'x', 'ozt', 0)`);
assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (2, 'XAG', 'x', 'pond', 0)`), /CHECK/);
});
});
describe('migratie 102: de catch-all in de invoermapping', () => {
function opzet101() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (101)`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE) STRICT;`);
run(`CREATE TABLE ProviderInputMap (
id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
symbol TEXT NOT NULL, externalId TEXT NOT NULL, quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN ('ozt')),
createdAt TEXT, UNIQUE (providerId, symbol)) STRICT;`);
run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool'), (2, 'andere')`);
for (const s of ['BTC', 'ETH', 'SOL']) run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId) VALUES (1, ?, 'crypto')`, [s]);
run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit) VALUES (1, 'XAU', 'commodity', 'ozt')`);
run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId) VALUES (1, 'WXT', 'wirex')`);
run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId) VALUES (2, 'BTC', 'crypto')`);
}
it('maakt van de crypto-regels van Eulerpool één catch-all en laat de rest staan', () => {
opzet101();
runMigrations();
const rijen = all(`SELECT providerId, symbol, externalId, quoteUnit, isCatchAll FROM ProviderInputMap
ORDER BY providerId, isCatchAll, symbol`).map(r => ({ ...r }));
assert.deepEqual(rijen, [
{ providerId: 1, symbol: 'WXT', externalId: 'wirex', quoteUnit: null, isCatchAll: 0 },
{ providerId: 1, symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt', isCatchAll: 0 },
{ providerId: 1, symbol: null, externalId: 'crypto', quoteUnit: null, isCatchAll: 1 },
{ providerId: 2, symbol: 'BTC', externalId: 'crypto', quoteUnit: null, isCatchAll: 0 },
]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('staat één catch-all per bron toe, zonder symbool, en een gewone regel mét', () => {
opzet101();
runMigrations();
assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, isCatchAll) VALUES (1, NULL, 'x', 1)`), /UNIQUE/);
assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, isCatchAll) VALUES (2, 'ETH', 'x', 1)`), /CHECK/);
assert.throws(() => run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, isCatchAll) VALUES (2, NULL, 'x', 0)`), /CHECK/);
});
});
describe('migratie 103: de tijd van Eulerpool-metaal', () => {
function opzet102() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (102)`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE) STRICT;`);
run(`CREATE TABLE ProviderOutputMap (
id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
jsonPath TEXT NOT NULL,
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')),
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s')),
createdAt TEXT) STRICT;`);
run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, kind TEXT NOT NULL) STRICT;`);
run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL,
observedAt TEXT NOT NULL, resolution TEXT NOT NULL, eur REAL, UNIQUE (assetId, providerId, observedAt, resolution)) STRICT;`);
run(`INSERT INTO Provider (id, key) VALUES (1, 'eulerpool'), (2, 'andere')`);
run(`CREATE TABLE ProviderEndpoint (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, capability TEXT NOT NULL,
quoteCurrency TEXT) STRICT;`);
run(`INSERT INTO ProviderEndpoint (providerId, capability, quoteCurrency) VALUES (1, 'daily_history', 'USD')`);
const kaart = (p, veld, pad, t) => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform)
VALUES (?, ?, 'daily_history', ?, ?)`, [p, pad, veld, t]);
kaart(1, 'time', '[].timestamp', 'ms_to_s');
kaart(1, 'USD', '[].price', 'last_per_day');
kaart(2, 'time', 'p[].0', 'ms_to_s');
run(`INSERT INTO Asset (id, symbol, kind) VALUES (1, 'XAG', 'metal'), (2, 'BTC', 'crypto')`);
const koers = (asset, bron) => run(`INSERT INTO PriceHistory (assetId, providerId, observedAt, resolution, eur)
VALUES (?, ?, '2025-12-01T00:00:00Z', 'day', 1)`, [asset, bron]);
koers(1, 1);
koers(2, 1);
koers(1, 2);
}
it('zet de tijdregel van Eulerpool om en haalt alleen zijn metaalkoersen weg', () => {
opzet102();
runMigrations();
assert.deepEqual(all(`SELECT providerId, internalField, transform FROM ProviderOutputMap ORDER BY providerId, internalField`).map(r => ({ ...r })), [
{ providerId: 1, internalField: 'price', transform: 'last_per_day' },
{ providerId: 1, internalField: 'time', transform: 'ms_to_s_local_close' },
{ providerId: 2, internalField: 'time', transform: 'ms_to_s' },
]);
assert.deepEqual(all(`SELECT assetId, providerId FROM PriceHistory ORDER BY assetId, providerId`).map(r => ({ ...r })), [
{ assetId: 1, providerId: 2 },
{ assetId: 2, providerId: 1 },
]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('laat de CHECK de nieuwe transformatie toe en een verzonnen nog steeds niet', () => {
opzet102();
runMigrations();
run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform)
VALUES (2, 'x', 'spot', 'price', 'ms_to_s_local_close')`);
assert.throws(() => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform)
VALUES (2, 'x', 'spot', 'price', 'verzonnen')`), /CHECK/);
});
});
describe('migratie 104: de rekenvaluta als gegeven van de portefeuille', () => {
function opzet103() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (103)`);
}
it('legt EUR neer, zonder verzonnen aanmaakdatum', () => {
opzet103();
runMigrations();
assert.deepEqual(all(`SELECT id, baseCurrency, createdAt FROM Portfolio`),
[{ id: 1, baseCurrency: 'EUR', createdAt: null }]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('laat de rij daarna niet meer wijzigen of weghalen', () => {
opzet103();
runMigrations();
assert.throws(() => run(`UPDATE Portfolio SET baseCurrency = 'USD'`), /fixed after creation/);
assert.throws(() => run(`DELETE FROM Portfolio`), /fixed after creation/);
});
it('laat een bestaande Portfolio-tabel staan', () => {
opzet103();
run(`CREATE TABLE Portfolio (id INTEGER PRIMARY KEY, baseCurrency TEXT NOT NULL, createdAt TEXT) STRICT`);
run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, 'USD', '2026-09-01T00:00:00Z')`);
runMigrations();
assert.deepEqual(all(`SELECT baseCurrency FROM Portfolio`), [{ baseCurrency: 'USD' }]);
});
});
describe('migratie 105: de geldkolommen heten fiat', () => {
function opzet104() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (104)`);
run(`CREATE TABLE Event (id INTEGER PRIMARY KEY, eurPrice REAL CHECK (eurPrice IS NULL OR eurPrice >= 0), eurFee REAL) STRICT`);
run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, eur REAL, usd REAL, CHECK (eur IS NOT NULL OR usd IS NOT NULL)) STRICT`);
run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT`);
run(`INSERT INTO Event VALUES (1, 30000, 12.5)`);
run(`INSERT INTO PriceHistory VALUES (1, 40000, 44000)`);
const zet = (scope, key, value) => run(`INSERT INTO Preference VALUES (?, ?, ?)`, [scope, key, value]);
zet('table.trades', 'view', JSON.stringify({ sort: { key: 'eurPrice', dir: 'desc' }, group: null }));
zet('table.valuations', 'view', JSON.stringify({ sort: { key: 'valuedOn', dir: 'desc' } }));
zet('table.prijsinfo', 'view', JSON.stringify({ sort: { key: 'eur', dir: 'asc' } }));
zet('table.transfers', 'view', JSON.stringify({ sort: { key: 'eurFee', dir: 'asc' } }));
zet('table.trades', 'sticky', 'geen json');
}
const view = scope => JSON.parse(one(`SELECT value FROM Preference WHERE scope = ? AND key = 'view'`, [scope]).value);
it('hernoemt de kolommen en houdt elke waarde vast', () => {
opzet104();
runMigrations();
assert.deepEqual(one(`SELECT id, fiatPrice, fiatFee FROM Event`), { id: 1, fiatPrice: 30000, fiatFee: 12.5 });
assert.deepEqual(one(`SELECT id, fiatPrice, usd FROM PriceHistory`), { id: 1, fiatPrice: 40000, usd: 44000 });
assert.throws(() => run(`UPDATE Event SET fiatPrice = -1`), /CHECK/, 'de CHECK is mee hernoemd');
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('neemt de bewaarde sortering op een hernoemde kolom mee, en verder niets', () => {
opzet104();
runMigrations();
assert.deepEqual(view('table.trades'), { sort: { key: 'fiatPrice', dir: 'desc' }, group: null });
assert.deepEqual(view('table.prijsinfo').sort, { key: 'fiatPrice', dir: 'asc' });
assert.deepEqual(view('table.valuations').sort, { key: 'valuedOn', dir: 'desc' }, 'een andere kolom blijft');
assert.deepEqual(view('table.transfers').sort, { key: 'eurFee', dir: 'asc' }, 'een andere scope blijft');
assert.equal(one(`SELECT value FROM Preference WHERE key = 'sticky'`).value, 'geen json');
});
});
describe('migratie 109: de totaalkolom van Boekingen heet fiatTotal', () => {
function opzet108() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (108)`);
run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT`);
const zet = (scope, key, value) => run(`INSERT INTO Preference VALUES (?, ?, ?)`, [scope, key, value]);
zet('table.trades', 'view', JSON.stringify({ sort: { key: 'eurTotal', dir: 'asc' }, group: 'type' }));
zet('table.transfers', 'view', JSON.stringify({ sort: { key: 'eurTotal', dir: 'asc' } }));
zet('table.trades', 'sticky', 'geen json');
}
const view = scope => JSON.parse(one(`SELECT value FROM Preference WHERE scope = ? AND key = 'view'`, [scope]).value);
it('zet de sortering op het totaal om, en laat de rest van de weergave staan', () => {
opzet108();
runMigrations();
assert.deepEqual(view('table.trades'), { sort: { key: 'fiatTotal', dir: 'asc' }, group: 'type' });
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('raakt een andere scope en een onleesbare voorkeur niet', () => {
opzet108();
runMigrations();
assert.deepEqual(view('table.transfers').sort, { key: 'eurTotal', dir: 'asc' });
assert.equal(one(`SELECT value FROM Preference WHERE key = 'sticky'`).value, 'geen json');
});
it('laat een sortering op een andere kolom staan', () => {
opzet108();
run(`UPDATE Preference SET value = ? WHERE scope = 'table.trades' AND key = 'view'`,
[JSON.stringify({ sort: { key: 'fiatPrice', dir: 'desc' } })]);
runMigrations();
assert.deepEqual(view('table.trades').sort, { key: 'fiatPrice', dir: 'desc' });
});
});
describe('migratie 107: één prijsveld, en PriceHistory.usd weg', () => {
function opzet106() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (106)`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY) STRICT; INSERT INTO Provider VALUES (1), (2), (3);`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT; INSERT INTO Asset VALUES (1);`);
run(`CREATE TABLE ProviderEndpoint (id INTEGER PRIMARY KEY, providerId INTEGER NOT NULL, capability TEXT NOT NULL,
quoteCurrency TEXT) STRICT;`);
run(`CREATE TABLE ProviderOutputMap (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL,
jsonPath TEXT NOT NULL,
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')),
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')),
createdAt TEXT
) STRICT;`);
run(`CREATE TABLE PriceHistory (
id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL, fiatPrice REAL, usd REAL,
resolution TEXT NOT NULL, observedAt TEXT NOT NULL, createdAt TEXT, updatedAt TEXT,
UNIQUE (assetId, providerId, observedAt, resolution), CHECK (fiatPrice IS NOT NULL OR usd IS NOT NULL)) STRICT;`);
run(`INSERT INTO ProviderEndpoint (providerId, capability, quoteCurrency) VALUES (1, 'spot', NULL), (2, 'daily_history', 'USD'), (3, 'spot', 'GBP')`);
const kaart = (id, p, cap, veld, pad) => run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform)
VALUES (?, ?, ?, ?, ?, 'none')`, [id, p, pad, cap, veld]);
kaart(1, 1, 'spot', 'EUR', 'EUR');
kaart(2, 1, 'spot', 'USD', 'USD');
kaart(3, 2, 'daily_history', 'time', '[].timestamp');
kaart(4, 2, 'daily_history', 'USD', '[].price');
kaart(5, 3, 'spot', 'price', 'rate');
run(`INSERT INTO PriceHistory (id, assetId, providerId, fiatPrice, usd, resolution, observedAt)
VALUES (1, 1, 1, 50000, 55000, 'day', '2026-01-01T00:00:00Z'), (2, 1, 1, NULL, 56000, 'day', '2026-01-02T00:00:00Z')`);
}
const mapping = () => all(`SELECT id, providerId, internalField FROM ProviderOutputMap ORDER BY id`);
it('maakt van het veld dat de adapter las `price`, en haalt de rest weg', () => {
opzet106();
runMigrations();
assert.deepEqual(mapping(), [
{ id: 1, providerId: 1, internalField: 'price' },
{ id: 3, providerId: 2, internalField: 'time' },
{ id: 4, providerId: 2, internalField: 'price' },
{ id: 5, providerId: 3, internalField: 'price' },
]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('weigert daarna een veld EUR of USD', () => {
opzet106();
runMigrations();
for (const veld of ['EUR', 'USD']) {
assert.throws(() => run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform)
VALUES (1, 'x', 'spot', ?, 'none')`, [veld]), /CHECK/, veld);
}
});
it('haalt de usd-kolom weg, en de rij die alleen een dollarkoers had', () => {
opzet106();
runMigrations();
assert.deepEqual(all(`SELECT id, fiatPrice FROM PriceHistory`), [{ id: 1, fiatPrice: 50000 }]);
assert.equal(all(`PRAGMA table_info(PriceHistory)`).some(k => k.name === 'usd'), false);
assert.throws(() => run(`INSERT INTO PriceHistory (assetId, providerId, resolution, observedAt)
VALUES (1, 1, 'day', '2026-01-03T00:00:00Z')`), /NOT NULL/, 'een rij zonder prijs kan niet meer');
});
});
describe('migratie 108: een portefeuille-voorstel noemt zijn munt', () => {
function opzet107() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (107)`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;
CREATE TABLE Event (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Valuation (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE AiProposal (
id INTEGER PRIMARY KEY, assetId INTEGER, accountId INTEGER, fromAccountId INTEGER, toAccountId INTEGER,
createdEventId INTEGER, createdAccountId INTEGER, createdValuationId INTEGER,
kind TEXT NOT NULL, name TEXT, accountKind TEXT, isOwnCustody INTEGER, eventType TEXT, amount INTEGER,
fiatPrice REAL, fiatFee REAL, balance INTEGER, observedAt TEXT, scope TEXT, key TEXT, value TEXT,
note TEXT, status TEXT NOT NULL, detail TEXT, resolvedAt TEXT, createdAt TEXT NOT NULL) STRICT`);
run(`INSERT INTO AiProposal (id, kind, name, note, status, createdAt) VALUES
(1, 'portfolio', 'Demo', 'een demo', 'pending', '2026-09-01T00:00:00Z'),
(2, 'portfolio', 'Oud', NULL, 'accepted', '2026-08-01T00:00:00Z')`);
run(`INSERT INTO AiProposal (id, kind, name, accountKind, isOwnCustody, status, createdAt)
VALUES (3, 'account', 'Kraken', 'exchange', 0, 'pending', '2026-09-02T00:00:00Z')`);
}
const rijen = () => all(`SELECT id, kind, name, currency, note, status FROM AiProposal ORDER BY id`).map(r => ({ ...r }));
it('geeft een portefeuille-voorstel EUR en elk ander geen munt', () => {
opzet107();
runMigrations();
assert.deepEqual(rijen(), [
{ id: 1, kind: 'portfolio', name: 'Demo', currency: 'EUR', note: 'een demo', status: 'pending' },
{ id: 2, kind: 'portfolio', name: 'Oud', currency: 'EUR', note: null, status: 'accepted' },
{ id: 3, kind: 'account', name: 'Kraken', currency: null, note: null, status: 'pending' },
]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('eist daarna een munt bij een portefeuille, en alleen daar', () => {
opzet107();
runMigrations();
const zet = (kind, extra, currency) => () => run(`INSERT INTO AiProposal (kind, name, ${extra ? 'accountKind, isOwnCustody, ' : ''}currency, status, createdAt)
VALUES (?, 'X', ${extra ? "'exchange', 0, " : ''}?, 'pending', '2026-09-03T00:00:00Z')`, [kind, currency]);
assert.throws(zet('portfolio', false, null), /CHECK/, 'een portefeuille zonder munt');
assert.throws(zet('portfolio', false, 'usd'), /CHECK/, 'een munt in kleine letters');
assert.throws(zet('account', true, 'USD'), /CHECK/, 'een munt op een ander voorstel');
assert.doesNotThrow(zet('portfolio', false, 'USD'));
});
it('laat een tabel die de kolom al heeft staan', () => {
opzet107();
run(`ALTER TABLE AiProposal ADD COLUMN currency TEXT`);
run(`UPDATE AiProposal SET currency = 'GBP' WHERE id = 1`);
run(`UPDATE AiProposal SET currency = 'EUR' WHERE id = 2`);
runMigrations();
assert.equal(one(`SELECT currency FROM AiProposal WHERE id = 1`).currency, 'GBP');
});
});
describe('migratie 125: een AI-boekingsvoorstel draagt een fee in eenheden', () => {
function opzet124() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (124)`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;
CREATE TABLE Event (id INTEGER PRIMARY KEY) STRICT; CREATE TABLE Valuation (id INTEGER PRIMARY KEY) STRICT;`);
run(`INSERT INTO Asset (id) VALUES (1); INSERT INTO Account (id) VALUES (1), (2);`);
run(`CREATE TABLE AiProposal (
id INTEGER PRIMARY KEY, assetId INTEGER, accountId INTEGER, fromAccountId INTEGER, toAccountId INTEGER,
createdEventId INTEGER, createdAccountId INTEGER, createdValuationId INTEGER,
kind TEXT NOT NULL, name TEXT, accountKind TEXT, isOwnCustody INTEGER, eventType TEXT, amount INTEGER,
fiatPrice REAL, fiatFee REAL, balance INTEGER, observedAt TEXT, scope TEXT, key TEXT, value TEXT,
currency TEXT, note TEXT, status TEXT NOT NULL, detail TEXT, resolvedAt TEXT, createdAt TEXT NOT NULL) STRICT`);
run(`INSERT INTO AiProposal (id, assetId, fromAccountId, kind, eventType, amount, fiatPrice, fiatFee,
observedAt, note, status, createdAt)
VALUES (1, 1, 1, 'event', 'sell', 500, 20.5, 1.25, '2026-10-01T10:00:00Z', 'een verkoop', 'accepted',
'2026-10-01T10:00:00Z')`);
run(`INSERT INTO AiProposal (id, kind, name, currency, status, createdAt)
VALUES (2, 'portfolio', 'Demo', 'USD', 'pending', '2026-10-02T00:00:00Z')`);
}
const voorstel = (eventType, unitFee, { from = 1, to = null, prijs = 10 } = {}) => () => run(
`INSERT INTO AiProposal (assetId, fromAccountId, toAccountId, kind, eventType, amount, fiatPrice, unitFee,
observedAt, status, createdAt)
VALUES (1, ?, ?, 'event', ?, 100, ?, ?, '2026-10-03T00:00:00Z', 'pending', '2026-10-03T00:00:00Z')`,
[from, to, eventType, prijs, unitFee]);
it('neemt elke bestaande waarde mee en geeft de kolom NULL', () => {
opzet124();
runMigrations();
const rijen = all(`SELECT id, kind, eventType, amount, fiatPrice, fiatFee, unitFee, currency, note, status
FROM AiProposal ORDER BY id`).map(r => ({ ...r }));
assert.deepEqual(rijen, [
{ id: 1, kind: 'event', eventType: 'sell', amount: 500, fiatPrice: 20.5, fiatFee: 1.25, unitFee: null,
currency: null, note: 'een verkoop', status: 'accepted' },
{ id: 2, kind: 'portfolio', eventType: null, amount: null, fiatPrice: null, fiatFee: null, unitFee: null,
currency: 'USD', note: null, status: 'pending' },
]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
it('laat een fee in eenheden alleen toe op een verkoop of een verplaatsing, en nooit negatief', () => {
opzet124();
runMigrations();
assert.doesNotThrow(voorstel('sell', 3));
assert.doesNotThrow(voorstel('transfer', 3, { to: 2, prijs: null }));
assert.throws(voorstel('buy', 3, { from: null, to: 1 }), /CHECK/, 'een aankoop: daar telt hij nergens');
assert.throws(voorstel('interest', 3, { from: null, to: 1, prijs: null }), /CHECK/, 'rente: idem');
assert.throws(voorstel('sell', -1), /CHECK/, 'een negatieve fee');
});
it('laat een tabel die de kolom al heeft staan', () => {
opzet124();
run(`ALTER TABLE AiProposal ADD COLUMN unitFee INTEGER`);
run(`UPDATE AiProposal SET unitFee = 7 WHERE id = 1`);
runMigrations();
assert.equal(one(`SELECT unitFee FROM AiProposal WHERE id = 1`).unitFee, 7);
});
});
describe('migratie 106: de weergavevaluta is weg', () => {
function opzet105() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (105)`);
run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope, key)) STRICT`);
run(`CREATE TABLE AiProposal (id INTEGER PRIMARY KEY, kind TEXT NOT NULL, scope TEXT, key TEXT, value TEXT,
status TEXT NOT NULL, detail TEXT, resolvedAt TEXT) STRICT`);
run(`INSERT INTO Preference VALUES ('app', 'currency', '"usd"'), ('app', 'theme', '"dark"')`);
run(`INSERT INTO AiProposal (id, kind, scope, key, value, status) VALUES
(1, 'preference', 'app', 'currency', '"usd"', 'pending'),
(2, 'preference', 'app', 'currency', '"eur"', 'accepted'),
(3, 'preference', 'app', 'theme', '"light"', 'pending')`);
}
it('haalt de voorkeur weg en laat de rest staan', () => {
opzet105();
runMigrations();
assert.deepEqual(all(`SELECT scope, key FROM Preference WHERE key <> 'locale'`), [{ scope: 'app', key: 'theme' }]);
});
it('wijst alleen een OPENSTAAND voorstel voor die voorkeur af, met een reden', () => {
opzet105();
runMigrations();
const rij = id => one(`SELECT status, detail, resolvedAt FROM AiProposal WHERE id = ?`, [id]);
assert.equal(rij(1).status, 'rejected');
assert.match(rij(1).detail, /app\.currency no longer exists/);
assert.ok(rij(1).resolvedAt);
assert.equal(rij(2).status, 'accepted', 'afgehandeld blijft afgehandeld');
assert.equal(rij(3).status, 'pending', 'een voorstel voor een andere voorkeur blijft staan');
});
});
describe('migratie 100: koersen van nul of lager weg', () => {
function opzet99() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (99)`);
run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL,
eur REAL, usd REAL, resolution TEXT NOT NULL, observedAt TEXT NOT NULL,
UNIQUE (assetId, providerId, observedAt, resolution), CHECK (eur IS NOT NULL OR usd IS NOT NULL)) STRICT;`);
const rij = (dag, eur, usd) => run(`INSERT INTO PriceHistory (assetId, providerId, eur, usd, resolution, observedAt)
VALUES (1, 1, ?, ?, 'day', ?)`, [eur, usd, `2012-01-0${dag}T00:00:00Z`]);
rij(1, 0, null);
rij(2, 0, 5);
rij(3, -1, 0);
rij(4, 40000, 0);
rij(5, 40000, 44000);
}
it('haalt een rij zonder positieve koers weg en maakt een losse nul leeg', () => {
opzet99();
runMigrations();
assert.deepEqual(all(`SELECT substr(observedAt, 9, 2) AS dag, fiatPrice AS eur FROM PriceHistory ORDER BY observedAt`).map(r => ({ ...r })), [
{ dag: '04', eur: 40000 },
{ dag: '05', eur: 40000 },
]);
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
});
});
function terugVoor97() {
run(`DROP TABLE PriceSource`);
consumerSourceVoor97();
}
function kolomnamenVoor105() {
run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapNa107`);
run(`CREATE TABLE ProviderOutputMap (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
jsonPath TEXT NOT NULL,
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')),
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')),
createdAt TEXT
) STRICT`);
run(`INSERT INTO ProviderOutputMap SELECT * FROM ProviderOutputMapNa107`);
run(`DROP TABLE ProviderOutputMapNa107`);
run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`);
if (all(`PRAGMA table_info(PriceHistory)`).length && !all(`PRAGMA table_info(PriceHistory)`).some(k => k.name === 'usd')) {
run(`ALTER TABLE PriceHistory ADD COLUMN usd REAL`);
}
for (const [tabel, nu, oud] of [
['Event', 'fiatPrice', 'eurPrice'], ['Event', 'fiatFee', 'eurFee'], ['Valuation', 'fiatRate', 'eurRate'],
['PriceHistory', 'fiatPrice', 'eur'], ['ProviderFixedRate', 'fiatPrice', 'eur'],
['AiProposal', 'fiatPrice', 'eur'], ['AiProposal', 'fiatFee', 'eurFee'],
]) {
const heeft = all(`PRAGMA table_info(${tabel})`).some(k => k.name === nu);
if (heeft) run(`ALTER TABLE ${tabel} RENAME COLUMN ${nu} TO ${oud}`);
}
}
function consumerSourceVoor97() {
run(`CREATE TABLE ConsumerSource (
id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
consumerKey TEXT NOT NULL, capability TEXT NOT NULL, rank INTEGER, status TEXT NOT NULL, createdAt TEXT,
UNIQUE (consumerKey, assetId, capability, providerId),
UNIQUE (consumerKey, assetId, capability, rank)
) STRICT;`);
}
describe('migratie 91: het USD-asset en de Frankfurter-bron', () => {
const bronkeuzeUsd = () => all(`SELECT capability, Provider.key AS bron, rank, status
FROM PriceSource
JOIN Provider ON Provider.id = PriceSource.providerId
JOIN Asset ON Asset.id = PriceSource.assetId
WHERE Asset.symbol = 'USD'
ORDER BY capability, rank`);
it('zet de bron, zijn mapping, het asset en de bronkeuze neer', () => {
opzet({ versie: 60 });
runMigrations();
const p = one(`SELECT id, isActive, authenticationRequired FROM Provider WHERE key = 'frankfurter'`);
assert.equal(p.isActive, 1, 'geen sleutel nodig, dus meteen actief');
assert.equal(p.authenticationRequired, 0);
assert.deepEqual(all(`SELECT capability, pathTemplate, quoteCurrency FROM ProviderEndpoint WHERE providerId = ? ORDER BY capability`, [p.id]),
[{ capability: 'daily_history', pathTemplate: '/v1/1999-01-04..', quoteCurrency: null },
{ capability: 'spot', pathTemplate: '/v1/latest', quoteCurrency: null }]);
assert.equal(all(`SELECT id FROM ProviderOutputMap WHERE providerId = ?`, [p.id]).length, 3);
assert.deepEqual({ ...one(`SELECT kind, unit, decimals FROM Asset WHERE symbol = 'USD'`) },
{ kind: 'fiat', unit: 'USD', decimals: 2 });
assert.deepEqual(bronkeuzeUsd(), [
{ capability: 'history', bron: 'frankfurter', rank: 1, status: 'confirmed' },
{ capability: 'spot', bron: 'frankfurter', rank: 1, status: 'confirmed' },
]);
});
it('laat een bestaand USD-asset en een bezette cel staan', () => {
opzet({ versie: 60 });
runMigrations();
terugVoor97();
run(`DELETE FROM Asset WHERE symbol = 'USD'`);
run(`DELETE FROM Provider WHERE key = 'frankfurter'`);
kolomnamenVoor105();
run(`UPDATE Schema SET version = 90`);
run(`INSERT INTO Asset (symbol, name, searchName, unit, subunit, displayUnit, decimals, kind)
VALUES ('USD', 'Mijn dollar', 'mijn dollar', 'USD', 'cent', 'unit', 2, 'fiat')`);
run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status)
SELECT 'tax', Asset.id, 'spot', Provider.id, 1, 'manual' FROM Asset, Provider
WHERE Asset.symbol = 'USD' AND Provider.key = 'fixed'`);
runMigrations();
assert.equal(one(`SELECT name FROM Asset WHERE symbol = 'USD'`).name, 'Mijn dollar');
assert.equal(all(`SELECT id FROM Asset WHERE symbol = 'USD'`).length, 1);
assert.deepEqual(bronkeuzeUsd(), [
{ capability: 'history', bron: 'frankfurter', rank: 1, status: 'confirmed' },
{ capability: 'spot', bron: 'fixed', rank: 1, status: 'manual' },
{ capability: 'spot', bron: 'frankfurter', rank: 2, status: 'confirmed' },
]);
});
});
describe('migratie 92: de Eulerpool-rijen op de endpoints die bestaan', () => {
function opzet91() {
opzet({ versie: 60 });
runMigrations();
terugVoor97();
kolomnamenVoor105();
run(`UPDATE Schema SET version = 91`);
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationParameterName, authenticationKind,
authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES ('eulerpool_metal', 'Eulerpool (metaal)', 'eulerpool (metaal)', 'https://api.eulerpool.com', 'token',
'query_key', 1, 1, 1, 0, '2026-01-01T00:00:00Z')`);
const p = one(`SELECT id FROM Provider WHERE key = 'eulerpool_metal'`).id;
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [p]);
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate)
VALUES (?, 'spot', 'GET', '/v1/commodities/{symbol}/quote', 'vs=eur'),
(?, 'daily_history', 'GET', '/v1/commodities/{symbol}/ohlcv', 'vs=eur&interval=1d')`, [p, p]);
run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform)
VALUES (?, 'spot', 'EUR', 'price', 'troy_ounce_to_gram')`, [p]);
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind)
VALUES (50, 'XAU', 'Goud', 'goud', 'ozt', 'g', 'unit', 7, 'metal')`);
run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status)
VALUES ('tax', 50, 'spot', ?, 2, 'manual'), ('tax', 50, 'daily_history', ?, 1, 'manual')`, [p, p]);
return p;
}
it('vervangt de endpoints en de mapping door één reeks in USD', () => {
const p = opzet91();
runMigrations();
assert.deepEqual(all(`SELECT capability, pathTemplate, queryTemplate, quoteCurrency FROM ProviderEndpoint WHERE providerId = ?`, [p]),
[{ capability: 'daily_history', pathTemplate: '/api/1/commodity/quotes/{symbol}', queryTemplate: null, quoteCurrency: 'USD' }]);
assert.deepEqual(all(`SELECT internalField, jsonPath, transform FROM ProviderOutputMap WHERE providerId = ? ORDER BY internalField`, [p]),
[{ internalField: 'price', jsonPath: '[].price', transform: 'troy_ounce_to_gram' },
{ internalField: 'time', jsonPath: '[].timestamp', transform: 'ms_to_s' }]);
});
it('haalt de spotkeuze weg en laat de reeks-keuze, de sleutelstand en de dekking staan', () => {
const p = opzet91();
runMigrations();
assert.deepEqual(all(`SELECT capability, rank, status FROM PriceSource WHERE providerId = ?`, [p]),
[{ capability: 'history', rank: 1, status: 'manual' }]);
assert.equal(one(`SELECT isActive FROM Provider WHERE id = ?`, [p]).isActive, 1);
assert.equal(one(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [p]).symbol, 'XAU');
});
});
describe('migratie 93: de maandlimiet per bron', () => {
function opzet92() {
opzet({ versie: 60 });
runMigrations();
run(`DROP TABLE ProviderCallLimit`);
kolomnamenVoor105();
run(`UPDATE Schema SET version = 92`);
for (const key of ['goldapi', 'mempool']) {
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES (?, ?, ?, 'https://voorbeeld.test', 'none', 0, 1, 1, 0, '2026-01-01T00:00:00Z')`, [key, key, key]);
}
}
const limiet = key => one(`SELECT callsPerMonth FROM ProviderCallLimit
JOIN Provider ON Provider.id = ProviderCallLimit.providerId WHERE Provider.key = ?`, [key])?.callsPerMonth ?? null;
it('zet de gratis limiet bij de bronnen die er een hebben, en niet bij de rest', () => {
opzet92();
runMigrations();
assert.equal(limiet('goldapi'), 100);
assert.equal(limiet('mempool'), null);
});
it('weigert een limiet van nul in de nieuwe tabel', () => {
opzet92();
runMigrations();
assert.throws(() => run(`UPDATE ProviderCallLimit SET callsPerMonth = 0`), /CHECK/);
});
});
describe('migratie 95: de Eulerpool-limiet', () => {
it('zet 1000 op 100.000, maar laat een limiet die de gebruiker zelf koos staan', () => {
opzet({ versie: 60 });
runMigrations();
kolomnamenVoor105();
run(`UPDATE Schema SET version = 94`);
for (const [key, limiet] of [['eulerpool', 1000], ['eulerpool_metal', 5000]]) {
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES (?, ?, ?, 'https://api.eulerpool.com', 'query_key', 1, 1, 1, 0, '2026-01-01T00:00:00Z')`, [key, key, key]);
run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt)
VALUES ((SELECT id FROM Provider WHERE key = ?), ?, '2026-09-24T00:00:00Z')`, [key, limiet]);
}
runMigrations();
const limiet = key => one(`SELECT callsPerMonth FROM ProviderCallLimit
JOIN Provider ON Provider.id = ProviderCallLimit.providerId WHERE Provider.key = ?`, [key]).callsPerMonth;
assert.equal(limiet('eulerpool'), 100000);
assert.equal(limiet('eulerpool_metal'), 5000, 'een eigen keuze blijft staan');
});
});
describe('migratie 111: balanceCapture op Asset', () => {
function opzet110() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (110)`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL,
description TEXT, unit TEXT NOT NULL, subunit TEXT,
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')),
createdAt TEXT, updatedAt TEXT,
UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
run(`CREATE TABLE AssetNote (id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, note TEXT) STRICT;`);
run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind,
taxCategory, createdAt, updatedAt)
VALUES (1,'EUR','Euro','euro','geld','EUR','cent','unit',2,'fiat','bank','2026-01-01','2026-02-01')`);
run(`INSERT INTO AssetNote (id, assetId, note) VALUES (1, 1, 'notitie')`);
}
it('zet de kolom vóór de tijdstempels en laat hem leeg, want NULL betekent "volgt soort"', () => {
opzet110();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
const kolommen = all(`SELECT name FROM pragma_table_info('Asset')`).map(r => r.name);
assert.deepEqual(kolommen.slice(-3), ['balanceCapture', 'createdAt', 'updatedAt']);
assert.deepEqual(one(`SELECT taxCategory, balanceCapture, createdAt, updatedAt FROM Asset WHERE id = 1`),
{ taxCategory: 'bank', balanceCapture: null, createdAt: '2026-01-01', updatedAt: '2026-02-01' });
});
it('weigert een waarde buiten 0 en 1, en laat de verwijzing van een andere tabel heel', () => {
opzet110();
runMigrations();
assert.throws(() => run(`UPDATE Asset SET balanceCapture = 2 WHERE id = 1`), /CHECK/i);
assert.doesNotThrow(() => run(`UPDATE Asset SET balanceCapture = 0 WHERE id = 1`));
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'AssetNote'`).sql, /REFERENCES Asset\(id\)/);
run(`DELETE FROM Asset WHERE id = 1`);
assert.equal(one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 0, 'de cascade wijst nog naar de echte tabel');
});
});
describe('migratie 112: buiten wordt een reikwijdte en geen categorie', () => {
function opzet111() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (111)`);
run(`CREATE TABLE Asset (
id INTEGER PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, searchName TEXT NOT NULL,
description TEXT, unit TEXT NOT NULL, subunit TEXT,
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')),
balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)),
createdAt TEXT, updatedAt TEXT,
UNIQUE (symbol), CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
run(`CREATE TABLE Account (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, searchName TEXT NOT NULL, description TEXT,
kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')),
isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)),
scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)),
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
closedAt TEXT,
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')),
createdAt TEXT, updatedAt TEXT,
CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL))
) STRICT;`);
run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`);
run(`CREATE TABLE Valuation (id INTEGER PRIMARY KEY,
accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE) STRICT;`);
run(`INSERT INTO Asset (id, symbol, name, searchName, unit, subunit, displayUnit, decimals, kind, taxCategory, balanceCapture, createdAt)
VALUES (1,'EUR','Euro','euro','EUR','cent','unit',2,'fiat','buiten',1,'2026-01-01'),
(2,'BTC','Bitcoin','bitcoin','BTC','sat','unit',8,'crypto','overig',NULL,NULL)`);
run(`INSERT INTO Account (id, name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxCategory, updatedAt)
VALUES (1,'Pensioen','pensioen','bank',0,0,1,NULL,'buiten','2026-02-01'),
(2,'Spaar','spaar','bank',0,0,0,'2026-03-01','bank',NULL)`);
run(`INSERT INTO Valuation (id, accountId, assetId) VALUES (1, 1, 1)`);
}
it('maakt van buiten de reikwijdte none, en laat de andere categorieën staan', () => {
opzet111();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.deepEqual(all(`SELECT id, taxCategory, taxScope, balanceCapture, createdAt FROM Asset ORDER BY id`), [
{ id: 1, taxCategory: null, taxScope: 'none', balanceCapture: 1, createdAt: '2026-01-01' },
{ id: 2, taxCategory: 'overig', taxScope: null, balanceCapture: null, createdAt: null }]);
assert.deepEqual(all(`SELECT id, taxCategory, taxScope, isActive, closedAt, updatedAt FROM Account ORDER BY id`), [
{ id: 1, taxCategory: null, taxScope: 'none', isActive: 1, closedAt: null, updatedAt: '2026-02-01' },
{ id: 2, taxCategory: 'bank', taxScope: null, isActive: 0, closedAt: '2026-03-01', updatedAt: null }]);
});
it('weigert buiten als categorie en een onbekende reikwijdte, en houdt de verwijzingen heel', () => {
opzet111();
runMigrations();
assert.throws(() => run(`UPDATE Account SET taxCategory = 'buiten' WHERE id = 2`), /CHECK/i);
assert.throws(() => run(`UPDATE Asset SET taxScope = 'onzin' WHERE id = 2`), /CHECK/i);
assert.doesNotThrow(() => run(`UPDATE Account SET taxScope = 'category' WHERE id = 2`));
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'Valuation'`).sql, /REFERENCES Account\(id\)/);
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'Valuation'`).sql, /REFERENCES Asset\(id\)/);
});
});
describe('migratie 113: rente op rente per afspraak', () => {
function opzet112() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (112)`);
run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT;`);
run(`INSERT INTO Account (id) VALUES (1)`);
run(`INSERT INTO Asset (id) VALUES (1), (2)`);
run(`CREATE TABLE InterestSchedule (
id INTEGER PRIMARY KEY, accountId INTEGER NOT NULL, principalAssetId INTEGER, payoutAssetId INTEGER NOT NULL,
note TEXT, principalAmount INTEGER, annualRate REAL, rateKind TEXT NOT NULL, creditCadence TEXT,
creditWeekday INTEGER, creditDayOfMonth INTEGER, startsOn TEXT NOT NULL, endsOn TEXT,
createdAt TEXT NOT NULL, updatedAt TEXT) STRICT;`);
run(`CREATE TABLE InterestCredit (id INTEGER PRIMARY KEY,
scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE, periodEnd TEXT) STRICT;`);
run(`INSERT INTO InterestSchedule (id, accountId, principalAssetId, payoutAssetId, note, principalAmount, annualRate,
rateKind, creditCadence, startsOn, endsOn, createdAt, updatedAt)
VALUES (1, 1, 2, 2, 'deposito', 1072628, 0.028, 'fixed', 'annual', '2025-08-18', '2029-08-18', '2025-08-18', '2026-01-02'),
(2, 1, NULL, 2, NULL, NULL, NULL, 'variable', NULL, '2024-01-01', NULL, '2024-01-01', NULL)`);
run(`INSERT INTO InterestCredit (id, scheduleId, periodEnd) VALUES (1, 1, '2026-01-01')`);
}
it('zet de kolom vóór de tijdstempels, op 0 voor elke bestaande afspraak', () => {
opzet112();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
const kolommen = all(`SELECT name FROM pragma_table_info('InterestSchedule')`).map(r => r.name);
assert.deepEqual(kolommen.slice(-3), ['compounding', 'createdAt', 'updatedAt']);
assert.deepEqual(all(`SELECT id, compounding, note, updatedAt FROM InterestSchedule ORDER BY id`), [
{ id: 1, compounding: 0, note: 'deposito', updatedAt: '2026-01-02' },
{ id: 2, compounding: 0, note: null, updatedAt: null }]);
});
it('weigert rente op rente waar hij niet kan, en laat de verwijzing heel', () => {
opzet112();
runMigrations();
assert.doesNotThrow(() => run(`UPDATE InterestSchedule SET compounding = 1 WHERE id = 1`));
assert.throws(() => run(`UPDATE InterestSchedule SET compounding = 1 WHERE id = 2`), /CHECK/i, 'variabel');
assert.throws(() => run(`UPDATE InterestSchedule SET compounding = 1, principalAmount = NULL WHERE id = 1`), /CHECK/i,
'een hoofdsom die het saldo volgt');
assert.throws(() => run(`UPDATE InterestSchedule SET compounding = 2 WHERE id = 1`), /CHECK/i);
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'InterestCredit'`).sql, /REFERENCES InterestSchedule\(id\)/);
run(`DELETE FROM InterestSchedule WHERE id = 1`);
assert.equal(one(`SELECT COUNT(*) AS n FROM InterestCredit`).n, 0, 'de cascade wijst nog naar de echte tabel');
});
});
describe('migratie 114: van wie een account is', () => {
function opzet113() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (113)`);
run(`CREATE TABLE Account (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, searchName TEXT NOT NULL, description TEXT,
kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')),
isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)),
scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)),
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
closedAt TEXT,
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')),
taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')),
createdAt TEXT, updatedAt TEXT,
CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL))
) STRICT;`);
run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`);
run(`CREATE TABLE Valuation (id INTEGER PRIMARY KEY,
accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION) STRICT;`);
run(`INSERT INTO Account (id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt,
taxCategory, taxScope, createdAt, updatedAt)
VALUES (1,'Spaarpot','spaarpot','van het kind','bank',0,0,1,NULL,'bank','category','2026-01-01','2026-02-01'),
(2,'Kluis','kluis',NULL,'vault',1,0,0,'2026-03-01',NULL,'none',NULL,NULL)`);
run(`INSERT INTO Valuation (id, accountId) VALUES (1, 1)`);
}
it('zet de kolom vóór de tijdstempels, leeg voor elk bestaand account, en laat de rest staan', () => {
opzet113();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
const kolommen = all(`SELECT name FROM pragma_table_info('Account')`).map(r => r.name);
assert.deepEqual(kolommen.slice(-4), ['taxScope', 'wealthScope', 'createdAt', 'updatedAt']);
assert.deepEqual(all(`SELECT id, description, taxCategory, taxScope, wealthScope, isActive, closedAt, createdAt, updatedAt
FROM Account ORDER BY id`), [
{ id: 1, description: 'van het kind', taxCategory: 'bank', taxScope: 'category', wealthScope: null,
isActive: 1, closedAt: null, createdAt: '2026-01-01', updatedAt: '2026-02-01' },
{ id: 2, description: null, taxCategory: null, taxScope: 'none', wealthScope: null,
isActive: 0, closedAt: '2026-03-01', createdAt: null, updatedAt: null }]);
});
it('weigert een onbekende waarde, kent geen expliciete eigen, en houdt de verwijzing heel', () => {
opzet113();
runMigrations();
assert.doesNotThrow(() => run(`UPDATE Account SET wealthScope = 'managed' WHERE id = 1`));
assert.doesNotThrow(() => run(`UPDATE Account SET wealthScope = 'none' WHERE id = 2`));
assert.throws(() => run(`UPDATE Account SET wealthScope = 'own' WHERE id = 1`), /CHECK/i, 'eigen is NULL');
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'Valuation'`).sql, /REFERENCES Account\(id\)/);
assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = 'IxAccountSearchName'`), 'de index is terug');
});
});
describe('elke herbouw houdt de bestaande rijen heel', () => {
const OUDE_KOLOMMEN = (t) => all(`PRAGMA table_info(${t})`).map(k => k.name);
function vulAlleKolommen(tabel, overslaan = []) {
for (const k of all(`PRAGMA table_info(${tabel})`)) {
if (k.pk || overslaan.includes(k.name)) continue;
const waarde = k.type === 'INTEGER' ? 7 : k.type === 'REAL' ? 1.25 : 'gevuld';
try { run(`UPDATE ${tabel} SET ${k.name} = ? WHERE ${k.name} IS NULL`, [waarde]); }
catch { }
}
}
function momentopname(tabellen) {
const uit = {};
for (const t of tabellen) uit[t] = { kolommen: OUDE_KOLOMMEN(t), rijen: all(`SELECT * FROM ${t} ORDER BY rowid`) };
return uit;
}
const HERNOEMD = {
'Event.eurPrice': 'fiatPrice', 'Event.eurFee': 'fiatFee', 'Valuation.eurRate': 'fiatRate',
'PriceHistory.eur': 'fiatPrice', 'ProviderFixedRate.eur': 'fiatPrice',
'AiProposal.eur': 'fiatPrice', 'AiProposal.eurFee': 'fiatFee',
};
const VERVALLEN_KOLOM = new Set(['PriceHistory.usd']);
const VERVALLEN_RIJ = { PriceHistory: r => r.eur == null };
function vergelijk(voor) {
for (const [tabel, { kolommen, rijen }] of Object.entries(voor)) {
const na = new Map(all(`SELECT * FROM ${tabel}`).map(r => [r.id, r]));
for (const oud of rijen) {
if (VERVALLEN_RIJ[tabel]?.(oud)) {
assert.equal(na.has(oud.id), false, `${tabel}: rij ${oud.id} hoort door de keten weg te zijn`);
continue;
}
const nu = na.get(oud.id);
assert.ok(nu, `${tabel}: rij ${oud.id} is bij een herbouw verdwenen`);
for (const k of kolommen) {
if (VERVALLEN_KOLOM.has(`${tabel}.${k}`)) continue;
const nieuw = HERNOEMD[`${tabel}.${k}`] ?? k;
assert.deepEqual(nu[nieuw], oud[k], `${tabel}.${k} is bij een herbouw niet meegekomen (rij ${oud.id})`);
}
}
}
}
it('neemt elke kolom van elke rij mee, over de hele keten van 60 naar nu', () => {
opzet({ versie: 60 });
zetRijen({ eurPrice: 30000, eurFee: 12.5, unitFee: 250 });
run(`INSERT INTO Provider (id, key, name, searchName, baseUrl, authenticationKind,
authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES (1, 'coingecko', 'CoinGecko', 'coingecko', 'https://api.coingecko.com', 'none',
0, 1, 1, 0, '2026-01-01T00:00:00Z')`);
run(`CREATE TABLE PriceHistory (
id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
eur REAL,
usd REAL,
resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')),
observedAt TEXT NOT NULL,
updatedAt TEXT,
UNIQUE (assetId, providerId, observedAt, resolution),
CHECK (eur IS NOT NULL OR usd IS NOT NULL)
) STRICT;`);
run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`);
run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`);
run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt)
VALUES (1, 1, 1, 30000.5, 32000.25, 'day', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z')`);
run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt)
VALUES (2, 1, 1, NULL, 31000.0, 'timestamp', '2026-01-03T09:00:00Z', NULL)`);
for (const t of ['Asset', 'Event', 'PriceHistory']) vulAlleKolommen(t);
const voor = momentopname(['Asset', 'Event', 'PriceHistory']);
runMigrations();
vergelijk(voor);
});
it('houdt AiProposal heel over migratie 83 en 84 heen', () => {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (82)`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY, symbol TEXT NOT NULL) STRICT;`);
run(`CREATE TABLE Account (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;`);
run(`INSERT INTO Asset (id, symbol) VALUES (1, 'BTC')`);
run(`INSERT INTO Account (id, name) VALUES (1, 'Bank')`);
run(`CREATE TABLE AiProposal (
id INTEGER PRIMARY KEY,
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio')),
name TEXT,
accountKind TEXT,
isOwnCustody INTEGER,
eventType TEXT,
amount INTEGER,
eur REAL,
eurFee REAL,
balance INTEGER,
observedAt TEXT,
note TEXT,
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
detail TEXT,
resolvedAt TEXT,
createdAt TEXT NOT NULL
) STRICT;`);
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
run(`INSERT INTO AiProposal (id, assetId, kind, eur, observedAt, note, status, createdAt)
VALUES (1, 1, 'price_point', 67000.5, '2026-09-01T00:00:00Z', 'van de AI', 'pending', '2026-09-01T10:00:00Z')`);
run(`INSERT INTO AiProposal (id, assetId, fromAccountId, toAccountId, kind, eventType, amount, eur, eurFee, observedAt, note, status, createdAt)
VALUES (2, 1, 1, 1, 'event', 'buy', 100000, 6700.0, 4.5, '2026-09-02T00:00:00Z', 'met kosten', 'pending', '2026-09-02T10:00:00Z')`);
run(`INSERT INTO AiProposal (id, assetId, accountId, kind, balance, observedAt, status, detail, resolvedAt, createdAt)
VALUES (3, 1, 1, 'valuation', 250000, '2026-09-03T00:00:00Z', 'accepted', 'goedgekeurd', '2026-09-03T11:00:00Z', '2026-09-03T10:00:00Z')`);
run(`INSERT INTO AiProposal (id, kind, name, accountKind, isOwnCustody, status, createdAt)
VALUES (4, 'account', 'Nieuwe beurs', 'exchange', 1, 'pending', '2026-09-04T10:00:00Z')`);
const voor = momentopname(['AiProposal']);
runMigrations();
vergelijk(voor);
const na = all(`SELECT createdEventId, createdAccountId, createdValuationId FROM AiProposal ORDER BY id`);
assert.ok(na.every(r => r.createdEventId === null && r.createdAccountId === null && r.createdValuationId === null),
'een voorstel van vóór 84 heeft geen spoor, en dat achteraf raden zou een herkomst VERZINNEN');
});
});
describe('migratie 115: de herkomst van een spot uit Atreides Services', () => {
function opzet114(metRij = false) {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (114)`);
bronTabellenStomp();
if (metRij) {
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', 'none', 0, 1, 1, 1, '2026-10-09')`);
}
}
it('zet één actieve systeemrij neer, zonder endpoints en zonder dekking', () => {
opzet114();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
const rij = one(`SELECT id, name, baseUrl, isActive, isSystem FROM Provider WHERE key = 'atreides_services'`);
assert.deepEqual({ name: rij.name, baseUrl: rij.baseUrl, isActive: rij.isActive, isSystem: rij.isSystem },
{ name: 'Atreides Services', baseUrl: '', isActive: 1, isSystem: 1 });
assert.equal(one(`SELECT COUNT(*) AS n FROM ProviderEndpoint WHERE providerId = ?`, [rij.id]).n, 0);
assert.equal(one(`SELECT COUNT(*) AS n FROM ProviderCoverage WHERE providerId = ?`, [rij.id]).n, 0);
});
it('laat een bestaande rij staan in plaats van er een tweede naast te zetten', () => {
opzet114(true);
runMigrations();
assert.equal(one(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'atreides_services'`).n, 1);
});
});
describe('migratie 116: de systeemrij van Atreides Services aanvullen waar hij ontbreekt', () => {
function opzet115(metRij = false) {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (115)`);
bronTabellenStomp();
if (metRij) {
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', 'none', 0, 1, 1, 1, '2026-10-09')`);
}
}
it('zet de rij neer op een database van 115 zonder', () => {
opzet115();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
const rij = one(`SELECT isActive, isSystem FROM Provider WHERE key = 'atreides_services'`);
assert.deepEqual({ isActive: rij?.isActive, isSystem: rij?.isSystem }, { isActive: 1, isSystem: 1 });
});
it('laat een database van 115 met de rij zoals hij is', () => {
opzet115(true);
runMigrations();
assert.equal(one(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'atreides_services'`).n, 1);
});
});
describe('migratie 117: de systeemrij ook bij kolomnamen tussen aanhalingstekens', () => {
function opzet116Geciteerd() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (116)`);
run(`CREATE TABLE "Provider" ("id" INTEGER PRIMARY KEY, "key" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL,
"searchName" TEXT NOT NULL, "baseUrl" TEXT NOT NULL, "authenticationHeaderName" TEXT,
"authenticationParameterName" TEXT, "authenticationKind" TEXT NOT NULL, "authenticationRequired" INTEGER NOT NULL,
"isActive" INTEGER NOT NULL, "isSeed" INTEGER NOT NULL, "isSystem" INTEGER NOT NULL, "createdAt" TEXT NOT NULL)`);
}
it('zet de rij neer als de DDL de kolommen tussen aanhalingstekens heeft', () => {
opzet116Geciteerd();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.equal(one(`SELECT COUNT(*) AS n FROM Provider WHERE key = 'atreides_services'`).n, 1);
});
it('slaat een fixture zonder de kolommen nog steeds over', () => {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (116)`);
run(`CREATE TABLE Provider (id INTEGER PRIMARY KEY, key TEXT NOT NULL)`);
runMigrations();
assert.equal(one(`SELECT COUNT(*) AS n FROM Provider`).n, 0);
});
});
describe('migratie 119: lightning in de walletcache', () => {
function opzet118() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (118)`);
run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE WalletSource (id INTEGER PRIMARY KEY, accountId INTEGER NOT NULL REFERENCES Account(id)) STRICT;`);
run(`CREATE TABLE Label (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE WalletTransaction (
id INTEGER PRIMARY KEY,
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE,
labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL,
txid TEXT NOT NULL,
address TEXT,
valueSats INTEGER NOT NULL,
feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0),
blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0),
blockTime TEXT,
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
createdAt TEXT NOT NULL,
UNIQUE (accountId, txid),
CHECK ((blockHeight IS NULL) = (blockTime IS NULL))
) STRICT;`);
run(`CREATE TABLE WalletTransactionEventLink (id INTEGER PRIMARY KEY,
walletTransactionId INTEGER NOT NULL REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`);
run(`CREATE TABLE ReconcileProposal (id INTEGER PRIMARY KEY,
walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`);
run(`INSERT INTO Account (id) VALUES (1)`);
run(`INSERT INTO WalletSource (id, accountId) VALUES (5, 1)`);
run(`INSERT INTO Label (id) VALUES (3)`);
run(`INSERT INTO WalletTransaction (id, accountId, sourceId, labelId, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt)
VALUES (7, 1, 5, 3, 'aa11', 'bc1qvoorbeeld', 12000, 150, 900001, '2026-01-02T03:04:05Z', 'out', '2026-01-02'),
(8, 1, NULL, NULL, 'bb22', NULL, 500, NULL, NULL, NULL, 'in', '2026-01-03')`);
run(`INSERT INTO WalletTransactionEventLink (id, walletTransactionId) VALUES (1, 7)`);
run(`INSERT INTO ReconcileProposal (id, walletTransactionId) VALUES (1, 8)`);
}
it('neemt elke rij met zijn id en elke kolom mee, als on-chain', () => {
opzet118();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.deepEqual(all(`SELECT * FROM WalletTransaction ORDER BY id`), [
{ id: 7, accountId: 1, sourceId: 5, labelId: 3, layer: 'onchain', kind: null, txid: 'aa11', address: 'bc1qvoorbeeld', memo: null,
valueSats: 12000, feeSats: 150, blockHeight: 900001, blockTime: '2026-01-02T03:04:05Z', direction: 'out', createdAt: '2026-01-02' },
{ id: 8, accountId: 1, sourceId: null, labelId: null, layer: 'onchain', kind: null, txid: 'bb22', address: null, memo: null,
valueSats: 500, feeSats: null, blockHeight: null, blockTime: null, direction: 'in', createdAt: '2026-01-03' }]);
});
it('houdt de koppeling en het voorstel op de echte tabelnaam, en zet de indexen terug', () => {
opzet118();
runMigrations();
for (const t of ['WalletTransactionEventLink', 'ReconcileProposal']) {
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = ?`, [t]).sql, /REFERENCES WalletTransaction\(id\)/);
}
assert.equal(one(`SELECT walletTransactionId AS id FROM WalletTransactionEventLink`).id, 7);
for (const ix of ['UxWalletTransactionAccountIdTxid', 'UxWalletTransactionSourceIdTxid',
'IxWalletTransactionAccountId', 'IxWalletTransactionSourceId', 'IxWalletTransactionLabelId']) {
assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = ?`, [ix]), `${ix} ontbreekt`);
}
});
it('weigert daarna een dubbele on-chain rij, en laat een lightning-rij met dezelfde hash toe', () => {
opzet118();
runMigrations();
assert.throws(() => run(`INSERT INTO WalletTransaction (accountId, layer, txid, valueSats, direction, createdAt)
VALUES (1, 'onchain', 'aa11', 1, 'in', '2026-01-04')`), /UNIQUE/);
assert.doesNotThrow(() => run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, valueSats, blockTime, direction, createdAt)
VALUES (1, 5, 'lightning', 'aa11', 21, '2026-01-04T00:00:00Z', 'in', '2026-01-04')`));
});
});
describe('migratie 120: het saldo van een lightning-bron', () => {
function opzet119() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (119)`);
run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE Connection (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE WalletSource (
id INTEGER PRIMARY KEY,
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
connectionId INTEGER REFERENCES Connection(id) ON DELETE RESTRICT,
value TEXT NOT NULL,
name TEXT,
kind TEXT NOT NULL CHECK (kind IN ('xpub','address','nwc','lnd')),
scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN ('p2tr','p2wpkh','p2sh-p2wpkh','p2pkh')),
createdAt TEXT NOT NULL,
updatedAt TEXT,
CHECK (scriptType IS NULL OR kind = 'xpub'),
CHECK ((kind = 'lnd') = (connectionId IS NOT NULL))
) STRICT;`);
run(`CREATE TABLE WalletAddress (id INTEGER PRIMARY KEY,
sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE) STRICT;`);
run(`INSERT INTO Account (id) VALUES (1)`);
run(`INSERT INTO Connection (id) VALUES (2)`);
run(`INSERT INTO WalletSource (id, accountId, connectionId, value, name, kind, scriptType, createdAt, updatedAt)
VALUES (3, 1, NULL, 'zpub-test', 'Ledger', 'xpub', 'p2wpkh', '2026-01-01', '2026-02-01'),
(4, 1, 2, '', NULL, 'lnd', NULL, '2026-01-02', NULL)`);
run(`INSERT INTO WalletAddress (id, sourceId) VALUES (1, 3)`);
}
it('neemt elke bron met zijn id en elke kolom mee, zonder saldo', () => {
opzet119();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.deepEqual(all(`SELECT * FROM WalletSource ORDER BY id`), [
{ id: 3, accountId: 1, connectionId: null, value: 'zpub-test', name: 'Ledger', kind: 'xpub', scriptType: 'p2wpkh',
balanceSats: null, lastScannedAt: null, createdAt: '2026-01-01', updatedAt: '2026-02-01' },
{ id: 4, accountId: 1, connectionId: 2, value: '', name: null, kind: 'lnd', scriptType: null,
balanceSats: null, lastScannedAt: null, createdAt: '2026-01-02', updatedAt: null }]);
});
it('houdt de adressen op de echte tabelnaam, en weigert een saldo op een on-chain bron', () => {
opzet119();
runMigrations();
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'WalletAddress'`).sql, /REFERENCES WalletSource\(id\)/);
assert.doesNotThrow(() => run(`UPDATE WalletSource SET balanceSats = 1, lastScannedAt = '2026-03-01' WHERE id = 4`));
assert.throws(() => run(`UPDATE WalletSource SET balanceSats = 1, lastScannedAt = '2026-03-01' WHERE id = 3`), /CHECK/);
assert.throws(() => run(`UPDATE WalletSource SET lastScannedAt = NULL WHERE id = 4`), /CHECK/, 'een saldo zonder moment');
});
it('laat na 124 een node op een on-chain bron toe, eist hem op lnd en weigert hem op nwc', () => {
opzet119();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.doesNotThrow(() => run(`UPDATE WalletSource SET connectionId = 2 WHERE id = 3`));
assert.throws(() => run(`UPDATE WalletSource SET connectionId = NULL WHERE id = 4`), /CHECK/);
assert.throws(() => run(`INSERT INTO WalletSource (accountId, connectionId, value, kind, createdAt) VALUES (1, 2, 'pk', 'nwc', '2026-01-03')`), /CHECK/);
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = 'WalletAddress'`).sql, /REFERENCES WalletSource\(id\)/);
assert.equal(one(`SELECT COUNT(*) AS n FROM WalletSource`).n, 2);
});
});
describe('migratie 121: een beweging binnen het account', () => {
function opzet120() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (120)`);
run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE WalletSource (id INTEGER PRIMARY KEY, accountId INTEGER NOT NULL REFERENCES Account(id)) STRICT;`);
run(`CREATE TABLE Label (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE WalletTransaction (
id INTEGER PRIMARY KEY,
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE,
labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL,
layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')),
txid TEXT NOT NULL,
address TEXT,
valueSats INTEGER NOT NULL,
feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0),
blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0),
blockTime TEXT,
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
createdAt TEXT NOT NULL,
CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL))
OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL))
) STRICT;`);
run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`);
run(`CREATE TABLE WalletTransactionEventLink (id INTEGER PRIMARY KEY,
walletTransactionId INTEGER NOT NULL REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`);
run(`CREATE TABLE ReconcileProposal (id INTEGER PRIMARY KEY,
walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE) STRICT;`);
run(`INSERT INTO Account (id) VALUES (1)`);
run(`INSERT INTO WalletSource (id, accountId) VALUES (5, 1)`);
run(`INSERT INTO Label (id) VALUES (3)`);
run(`INSERT INTO WalletTransaction (id, accountId, sourceId, labelId, layer, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt)
VALUES (7, 1, 5, 3, 'onchain', 'aa11', 'bc1qvoorbeeld', 12000, 150, 900001, '2026-01-02T03:04:05Z', 'out', '2026-01-02'),
(8, 1, 5, NULL, 'lightning', 'cc33', NULL, 21, 1, NULL, '2026-01-03T00:00:00Z', 'out', '2026-01-03')`);
run(`INSERT INTO WalletTransactionEventLink (id, walletTransactionId) VALUES (1, 7)`);
run(`INSERT INTO ReconcileProposal (id, walletTransactionId) VALUES (1, 8)`);
}
it('neemt elke rij met zijn id en elke kolom mee, zonder soort', () => {
opzet120();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.deepEqual(all(`SELECT * FROM WalletTransaction ORDER BY id`), [
{ id: 7, accountId: 1, sourceId: 5, labelId: 3, layer: 'onchain', kind: null, txid: 'aa11', address: 'bc1qvoorbeeld', memo: null,
valueSats: 12000, feeSats: 150, blockHeight: 900001, blockTime: '2026-01-02T03:04:05Z', direction: 'out', createdAt: '2026-01-02' },
{ id: 8, accountId: 1, sourceId: 5, labelId: null, layer: 'lightning', kind: null, txid: 'cc33', address: null, memo: null,
valueSats: 21, feeSats: 1, blockHeight: null, blockTime: '2026-01-03T00:00:00Z', direction: 'out', createdAt: '2026-01-03' }]);
for (const t of ['WalletTransactionEventLink', 'ReconcileProposal']) {
assert.match(one(`SELECT sql FROM sqlite_master WHERE name = ?`, [t]).sql, /REFERENCES WalletTransaction\(id\)/);
}
assert.equal(one(`SELECT walletTransactionId AS id FROM ReconcileProposal`).id, 8);
});
it('laat een memo alleen op lightning toe, en niet langer dan 2000 tekens (migratie 123)', () => {
opzet120();
runMigrations();
assert.doesNotThrow(() => run(`UPDATE WalletTransaction SET memo = 'Koffie' WHERE id = 8`));
assert.throws(() => run(`UPDATE WalletTransaction SET memo = 'Koffie' WHERE id = 7`), /CHECK/);
assert.throws(() => run(`UPDATE WalletTransaction SET memo = ? WHERE id = 8`, ['x'.repeat(2001)]), /CHECK/);
assert.equal(one(`SELECT walletTransactionId AS id FROM ReconcileProposal`).id, 8, 'het voorstel hangt nog aan zijn beweging');
});
it('laat een kanaal alleen on-chain toe en een betaling aan jezelf alleen op lightning', () => {
opzet120();
runMigrations();
assert.doesNotThrow(() => run(`UPDATE WalletTransaction SET kind = 'channel' WHERE id = 7`));
assert.doesNotThrow(() => run(`UPDATE WalletTransaction SET kind = 'self' WHERE id = 8`));
assert.throws(() => run(`UPDATE WalletTransaction SET kind = 'self' WHERE id = 7`), /CHECK/);
assert.throws(() => run(`UPDATE WalletTransaction SET kind = 'channel' WHERE id = 8`), /CHECK/);
assert.throws(() => run(`UPDATE WalletTransaction SET kind = 'iets' WHERE id = 7`), /CHECK/);
});
});
describe('migratie 122: een forward als rente', () => {
function opzet121() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (121)`);
run(`CREATE TABLE Account (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE Asset (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE WalletTransaction (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE Event (id INTEGER PRIMARY KEY) STRICT;`);
run(`CREATE TABLE ReconcileProposal (
id INTEGER PRIMARY KEY,
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE,
resolvedEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL,
transactionHash TEXT,
amount INTEGER NOT NULL CHECK (amount > 0),
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
suggestedType TEXT NOT NULL CHECK (suggestedType IN ('trade','transfer')),
status TEXT NOT NULL CHECK (status IN ('pending','accepted_trade','accepted_transfer','ignored','auto_linked')),
detectedAt TEXT NOT NULL
) STRICT;`);
run(`INSERT INTO Account (id) VALUES (1)`);
run(`INSERT INTO Asset (id) VALUES (2)`);
run(`INSERT INTO WalletTransaction (id) VALUES (3)`);
run(`INSERT INTO Event (id) VALUES (4)`);
run(`INSERT INTO ReconcileProposal VALUES (9, 1, 2, 3, 4, 'fwd:1', 7, 'in', 'trade', 'auto_linked', '2026-10-19')`);
}
it('neemt elk voorstel mee met zijn id, en laat daarna accepted_interest toe', () => {
opzet121();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.deepEqual(all(`SELECT * FROM ReconcileProposal`), [{ id: 9, accountId: 1, assetId: 2, walletTransactionId: 3,
resolvedEventId: 4, transactionHash: 'fwd:1', amount: 7, direction: 'in', suggestedType: 'trade',
status: 'auto_linked', detectedAt: '2026-10-19' }]);
assert.doesNotThrow(() => run(`UPDATE ReconcileProposal SET status = 'accepted_interest' WHERE id = 9`));
assert.throws(() => run(`UPDATE ReconcileProposal SET status = 'iets' WHERE id = 9`), /CHECK/);
for (const ix of ['IxReconcileProposalStatus', 'UxReconcileProposalLink', 'IxReconcileProposalResolvedEventId']) {
assert.ok(one(`SELECT 1 AS x FROM sqlite_master WHERE name = ?`, [ix]), `${ix} ontbreekt`);
}
});
});
describe('migratie 127: een bestaande portefeuille houdt Nederlands', () => {
function opzet126(rijen) {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (126)`);
run(`CREATE TABLE Preference (scope TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
createdAt TEXT, PRIMARY KEY (scope, key)) STRICT;`);
for (const [scope, key, value] of rijen) run(`INSERT INTO Preference (scope, key, value) VALUES (?, ?, ?)`, [scope, key, value]);
}
const taal = () => one(`SELECT value FROM Preference WHERE scope = 'app' AND key = 'locale'`)?.value;
it('zet Nederlands vast waar geen taal is opgeslagen', () => {
opzet126([['app', 'theme', '"dark"']]);
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.equal(taal(), '"nl"');
assert.equal(one(`SELECT value FROM Preference WHERE key = 'theme'`).value, '"dark"');
});
it('laat een gekozen taal staan', () => {
opzet126([['app', 'locale', '"en"']]);
runMigrations();
assert.equal(taal(), '"en"');
});
});
describe('migratie 128: de bron achter een dagkoers van de dienst', () => {
function opzet127() {
openDatabase(':memory:');
run(`CREATE TABLE Schema (version INTEGER NOT NULL) STRICT;`);
run(`INSERT INTO Schema (version) VALUES (127)`);
run(`CREATE TABLE PriceHistory (id INTEGER PRIMARY KEY, assetId INTEGER NOT NULL, providerId INTEGER NOT NULL,
fiatPrice REAL NOT NULL, resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')), observedAt TEXT NOT NULL,
createdAt TEXT, updatedAt TEXT, UNIQUE (assetId, providerId, observedAt, resolution)) STRICT;`);
run(`INSERT INTO PriceHistory VALUES (7, 1, 2, 61000, 'day', '2026-10-26T00:00:00Z', '2026-10-27T00:00:00Z', NULL)`);
}
it('houdt elke rij, zet de kolom vóór de tijdstempels en laat hem leeg', () => {
opzet127();
runMigrations();
assert.equal(readVersion(), CONFIG.SCHEMA_VERSION);
assert.deepEqual(one(`SELECT id, fiatPrice, observedAt, createdAt, sourceName FROM PriceHistory`),
{ id: 7, fiatPrice: 61000, observedAt: '2026-10-26T00:00:00Z', createdAt: '2026-10-27T00:00:00Z', sourceName: null });
assert.deepEqual(all(`PRAGMA table_info(PriceHistory)`).map(c => c.name).slice(-3), ['sourceName', 'createdAt', 'updatedAt']);
assert.ok(all(`PRAGMA index_list(PriceHistory)`).some(i => i.unique && i.origin === 'u'), 'de UNIQUE is mee herbouwd');
assert.deepEqual(all(`PRAGMA index_list(PriceHistory)`).filter(i => i.origin === 'c').map(i => i.name).sort(),
['IxPriceHistoryAssetIdObservedAt', 'IxPriceHistoryProviderId'], 'de indexen zijn mee herbouwd');
});
});