2532 lines
136 KiB
JavaScript
2532 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');
|
||
|
|
});
|
||
|
|
});
|