Versie 0.2.0

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

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
// Generated, do not edit. sha256:a2c33f19cc145348f36ba31f41736c0be16d371654b28747c636f79229822fe6
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
listAccounts, listSelectableAccounts, deactivateCheck, deactivateAccount, reactivateAccount,
} = await import('../../server/data/accounts.js');
const { eventsNetByAccount } = await import('../../server/data/events.js');
let btc, kluis, tijdelijk;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
btc = harness.exports.lastId();
kluis = account('Kluis');
tijdelijk = account('Tijdelijk');
});
function account(naam, { scanEnabled = 0 } = {}) {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES (?,?, 'wallet', 1, ?, 1, NULL)`, [naam, naam.toLowerCase(), scanEnabled]);
return harness.exports.lastId();
}
function koop(accountId, sats, at = '2024-05-01T10:00:00Z') {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', ?, ?, 50000, ?, 'manual', ?)`, [btc, at, sats, accountId, at]);
}
const rij = id => harness.exports.one(`SELECT * FROM Account WHERE id = ?`, [id]);
describe('non-actief: uit de keuzelijst, maar niet uit de stand', () => {
it('blijft volledig meetellen in het grootboek', async () => {
koop(tijdelijk, 100_000);
const voor = eventsNetByAccount(tijdelijk, btc, '2030-01-01T00:00:00Z');
await deactivateAccount(tijdelijk, '2024-06-01T00:00:00Z');
assert.equal(eventsNetByAccount(tijdelijk, btc, '2030-01-01T00:00:00Z'), voor,
'deactiveren is géén verwijderen: het saldo verandert niet');
assert.equal(voor, 100_000);
});
it('blijft in listAccounts staan, want die voedt de totalen', () => {
harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]);
assert.deepEqual(listAccounts().map(a => a.name).sort(), ['Kluis', 'Tijdelijk']);
});
it('verdwijnt wél uit de keuzelijst', () => {
harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]);
assert.deepEqual(listSelectableAccounts().map(a => a.name), ['Kluis']);
});
it('komt met de expliciete opt-in tóch mee, actieve eerst', () => {
harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]);
assert.deepEqual(listSelectableAccounts({ metGesloten: true }).map(a => a.name), ['Kluis', 'Tijdelijk']);
});
it('houdt de huidige waarde van een bewerkformulier in de lijst', () => {
harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]);
assert.deepEqual(listSelectableAccounts({ ookId: tijdelijk }).map(a => a.name), ['Kluis', 'Tijdelijk']);
});
it('laat de andere non-actieve accounts wél weg bij ookId', () => {
const derde = account('Derde');
harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id IN (?,?)`, [tijdelijk, derde]);
assert.deepEqual(listSelectableAccounts({ ookId: tijdelijk }).map(a => a.name), ['Kluis', 'Tijdelijk']);
});
it('verandert niets als ookId leeg of onbekend is', () => {
harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2024-06-01T00:00:00Z' WHERE id = ?`, [tijdelijk]);
for (const opt of [{}, { ookId: null }, { ookId: undefined }, { ookId: 0 }, { ookId: 9999 }]) {
assert.deepEqual(listSelectableAccounts(opt).map(a => a.name), ['Kluis'], JSON.stringify(opt));
}
});
});
describe('deactivateCheck', () => {
const soorten = chk => chk.findings.map(f => f.kind).sort();
it('meldt niets bij een leeg, ongebruikt account', () => {
const chk = deactivateCheck(kluis);
assert.equal(chk.found, true);
assert.deepEqual(chk.findings, []);
});
it('geeft found:false voor een onbekend account', () => {
assert.deepEqual(deactivateCheck(9999), { found: false, findings: [] });
});
it('meldt dat het scannen aanstaat', () => {
const a = account('Scanner', { scanEnabled: 1 });
assert.deepEqual(soorten(deactivateCheck(a)), ['scanEnabled']);
});
it('meldt wallet-bronnen', () => {
harness.exports.run(
`INSERT INTO WalletSource (accountId, kind, value, name, createdAt)
VALUES (?, 'xpub', 'zpub', 'Ledger', '2024-01-01T00:00:00Z')`, [kluis]);
assert.deepEqual(soorten(deactivateCheck(kluis)), ['walletSources']);
});
it('meldt een rente-afspraak zonder einddatum', () => {
harness.exports.run(
`INSERT INTO InterestSchedule (accountId, payoutAssetId, rateKind, startsOn, compounding, createdAt)
VALUES (?,?, 'variable', '2024-01-01', 0, '2024-01-01T00:00:00Z')`, [kluis, btc]);
assert.deepEqual(soorten(deactivateCheck(kluis)), ['openSchedules']);
});
it('meldt een afspraak met een einddatum in de toekomst, maar niet één die al afliep', () => {
harness.exports.run(
`INSERT INTO InterestSchedule (accountId, payoutAssetId, rateKind, startsOn, endsOn, compounding, createdAt)
VALUES (?,?, 'variable', '2020-01-01', '2020-12-31', 0, '2020-01-01T00:00:00Z')`, [kluis, btc]);
assert.deepEqual(soorten(deactivateCheck(kluis)), [], 'een afgelopen afspraak is geen gevolg');
harness.exports.run(
`INSERT INTO InterestSchedule (accountId, payoutAssetId, rateKind, startsOn, endsOn, compounding, createdAt)
VALUES (?,?, 'variable', '2024-01-01', '2099-12-31', 0, '2024-01-01T00:00:00Z')`, [kluis, btc]);
assert.deepEqual(soorten(deactivateCheck(kluis)), ['openSchedules']);
});
it('meldt openstaande reconcile-voorstellen, maar niet afgehandelde', () => {
const voorstel = status => harness.exports.run(
`INSERT INTO ReconcileProposal (accountId, assetId, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, 1000, 'in', 'transfer', ?, '2024-05-01T00:00:00Z')`, [kluis, btc, status]);
voorstel('ignored');
assert.deepEqual(soorten(deactivateCheck(kluis)), [], 'afgehandeld is geen gevolg');
voorstel('pending');
assert.deepEqual(soorten(deactivateCheck(kluis)), ['pendingProposals']);
});
it('meldt een saldo dat niet nul is, met het bedrag per asset', () => {
koop(kluis, 250_000);
const chk = deactivateCheck(kluis);
const saldo = chk.findings.find(f => f.kind === 'balance');
assert.ok(saldo, 'een saldo hoort een bevinding te zijn');
assert.equal(saldo.balances[0].saldo, 250_000);
});
it('meldt een saldo van nul juist NIET', () => {
koop(kluis, 100_000);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fromAccountId, source, createdAt)
VALUES (?, 'sell', '2024-06-01T10:00:00Z', 100000, 50000, ?, 'manual', '2024-06-01T10:00:00Z')`,
[btc, kluis]);
const chk = deactivateCheck(kluis);
assert.equal(chk.findings.some(f => f.kind === 'balance'), false, 'leeg account, geen saldo-bevinding');
assert.equal(chk.findings.some(f => f.kind === 'events'), true, 'de historie wordt wél gemeld');
});
it('meldt een saldo dat alléén uit een balans komt', () => {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?, '2026-06-30', ?, 123456, 1.0, '2026-06-30T00:00:00.000Z', 'manual')`, [btc, kluis]);
const chk = deactivateCheck(kluis);
const saldo = chk.findings.find(f => f.kind === 'balance');
assert.ok(saldo, 'een gemeten balans is net zo goed een saldo');
assert.deepEqual(saldo.balances, [{ assetId: btc, saldo: 123456 }]);
assert.equal(chk.findings.some(f => f.kind === 'events'), false, 'en er is geen historie');
});
it('meldt de fee van een verplaatsing naar dezelfde rekening', () => {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, unitFee, fromAccountId, toAccountId, source, createdAt)
VALUES (?, 'transfer', '2024-06-01T10:00:00Z', 100000, 250, ?, ?, 'manual', '2024-06-01T10:00:00Z')`,
[btc, kluis, kluis]);
const saldo = deactivateCheck(kluis).findings.find(f => f.kind === 'balance');
assert.ok(saldo, 'de fee is een saldo');
assert.deepEqual(saldo.balances, [{ assetId: btc, saldo: -250 }]);
});
it('merkt de events-bevinding aan als informatief', () => {
koop(kluis, 100_000);
const ev = deactivateCheck(kluis).findings.find(f => f.kind === 'events');
assert.equal(ev.informational, true, 'de historie blijft en blijft meetellen: dat is geen waarschuwing');
});
it('verandert niets aan de database', () => {
koop(kluis, 100_000);
const voor = rij(kluis);
deactivateCheck(kluis);
assert.deepEqual({ ...rij(kluis) }, { ...voor });
});
});
describe('deactiveren en heractiveren', () => {
it('zet de vlag, de datum én het scannen uit', async () => {
const a = account('Scanner', { scanEnabled: 1 });
await deactivateAccount(a, '2024-06-01T00:00:00Z');
const r = rij(a);
assert.equal(r.isActive, 0);
assert.equal(r.closedAt, '2024-06-01T00:00:00Z');
assert.equal(r.scanEnabled, 0, 'dat mag stil, want deactivateCheck heeft het gemeld');
});
it('zet bij heractiveren het scannen NIET terug aan', async () => {
const a = account('Scanner', { scanEnabled: 1 });
await deactivateAccount(a, '2024-06-01T00:00:00Z');
await reactivateAccount(a);
const r = rij(a);
assert.equal(r.isActive, 1);
assert.equal(r.closedAt, null, 'de CHECK eist dat een actief account geen afsluitdatum heeft');
assert.equal(r.scanEnabled, 0, 'scannen aanzetten is een eigen, expliciete handeling');
});
it('weigert een onbekend account in beide richtingen', async () => {
await assert.rejects(() => deactivateAccount(9999), (e) => e.key === 'accounts.unknownAccount');
await assert.rejects(() => reactivateAccount(9999), (e) => e.key === 'accounts.unknownAccount');
});
});
+280
View File
@@ -0,0 +1,280 @@
// Generated, do not edit. sha256:8974817703a099c61e2ed0be93a4edca07beef7a6be37fd3761e3616029a859f
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG, ENUMS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { logAiToolCall, listAiToolCalls, countAiToolCalls, lastAiToolCallAt, aiSessions,
aiToolCallMessages, countAiToolCallsSince } = await import('../../server/data/ai-audit.js');
const { takeChanges } = await import('../../server/data/_events.js');
const regel = (extra = {}) => ({
sessionId: 'sessie-1', client: 'voorbeeld-client', tool: 'connection_test',
outcome: 'ok', ...extra,
});
beforeEach(() => { harness.recreate(); takeChanges(); });
describe('ai-audit: vastleggen', () => {
it('legt een gelukte aanroep vast met alles wat er nodig is om hem te verantwoorden', async () => {
const id = await logAiToolCall(regel({
arguments: { note: 'hallo' }, fieldsShared: [], charactersOut: 42,
}));
assert.ok(id > 0, 'geeft het id van de nieuwe regel terug');
const [r] = listAiToolCalls();
assert.equal(r.id, id);
assert.equal(r.sessionId, 'sessie-1');
assert.equal(r.client, 'voorbeeld-client');
assert.equal(r.tool, 'connection_test');
assert.equal(r.outcome, 'ok');
assert.equal(r.charactersOut, 42);
assert.deepEqual(r.fieldsShared, [], 'een lege lijst betekent: er ging geen enkel veld mee');
assert.equal(JSON.parse(r.arguments).note, 'hallo');
assert.ok(r.occurredAt, 'en er staat een moment bij');
});
it('persisteert', async () => {
const voor = harness.calls.persist;
await logAiToolCall(regel());
assert.equal(harness.calls.persist, voor + 1, 'een auditregel die niet op schijf komt, bestaat niet');
});
it('houdt het verschil tussen "niets gedeeld" en "onbekend wat er gedeeld is"', async () => {
await logAiToolCall(regel({ fieldsShared: [] }));
await logAiToolCall(regel({ sessionId: 'sessie-2' }));
const rijen = listAiToolCalls();
const leeg = rijen.find(r => r.sessionId === 'sessie-1');
const onbekend = rijen.find(r => r.sessionId === 'sessie-2');
assert.deepEqual(leeg.fieldsShared, []);
assert.equal(onbekend.fieldsShared, null);
});
it('bewaart de veldnamen en niet hun waarden', async () => {
await logAiToolCall(regel({ fieldsShared: ['name', 'symbol'] }));
const [r] = listAiToolCalls();
assert.deepEqual(r.fieldsShared, ['name', 'symbol']);
});
});
describe('ai-audit: welke serverversie een aanroep afhandelde', () => {
it('legt de versie vast als de aanroeper hem meegeeft', async () => {
await logAiToolCall(regel({ serverVersion: '2' }));
assert.equal(listAiToolCalls()[0].serverVersion, '2');
});
it('laat hem LEEG als de aanroeper hem niet kent, en vult niets in', async () => {
await logAiToolCall(regel());
assert.equal(listAiToolCalls()[0].serverVersion, null);
});
});
describe('ai-audit: de actieve koppelingen', () => {
it('groepeert per sessie, met aantal en laatste moment', async () => {
await logAiToolCall(regel({ sessionId: 's1', serverVersion: '2', occurredAt: '2026-08-15T10:00:00.000Z' }));
await logAiToolCall(regel({ sessionId: 's1', serverVersion: '2', occurredAt: '2026-08-15T10:05:00.000Z' }));
await logAiToolCall(regel({ sessionId: 's2', serverVersion: '2', occurredAt: '2026-08-15T09:00:00.000Z' }));
const s = aiSessions(24 * 365 * 100);
assert.equal(s.length, 2);
assert.equal(s[0].sessionId, 's1', 'nieuwste eerst');
assert.equal(s[0].calls, 2);
assert.equal(s[0].lastAt, '2026-08-15T10:05:00.000Z');
});
it('markeert een sessie zonder serverversie, want die kent de schakelaar niet', async () => {
await logAiToolCall(regel({ sessionId: 'oud', occurredAt: '2026-08-15T10:00:00.000Z' }));
await logAiToolCall(regel({ sessionId: 'nieuw', serverVersion: '2', occurredAt: '2026-08-15T10:01:00.000Z' }));
const s = aiSessions(24 * 365 * 100);
assert.equal(s.find(x => x.sessionId === 'oud').zonderVersie, true);
assert.equal(s.find(x => x.sessionId === 'nieuw').zonderVersie, false);
});
it('laat een sessie buiten het venster weg', async () => {
await logAiToolCall(regel({ sessionId: 'oud', occurredAt: '2020-01-01T00:00:00.000Z' }));
assert.deepEqual(aiSessions(24), []);
});
});
describe('ai-audit: de guards', () => {
it('weigert een onbekende uitkomst', async () => {
await assert.rejects(() => logAiToolCall(regel({ outcome: 'geslaagd' })), /Invalid outcome/);
assert.equal(listAiToolCalls().length, 0, 'en er staat niets in de tabel');
});
it('weigert een regel zonder sessie', async () => {
await assert.rejects(() => logAiToolCall(regel({ sessionId: null })), /without a session/);
assert.equal(listAiToolCalls().length, 0);
});
it('weigert een regel zonder toolnaam', async () => {
await assert.rejects(() => logAiToolCall(regel({ tool: '' })), /without a tool name/);
assert.equal(listAiToolCalls().length, 0);
});
it('weigert een negatief aantal verstuurde tekens', async () => {
await assert.rejects(() => logAiToolCall(regel({ charactersOut: -1 })));
assert.equal(listAiToolCalls().length, 0);
});
it('legt élke uitkomst uit de registry vast, ook de geweigerde', async () => {
for (const uitkomst of ENUMS.AI_CALL_OUTCOME) {
await logAiToolCall(regel({ outcome: uitkomst, detail: 'reden' }));
}
assert.equal(listAiToolCalls().length, ENUMS.AI_CALL_OUTCOME.length);
});
});
describe('ai-audit: teruglezen', () => {
it('geeft de nieuwste eerst', async () => {
const t = '2026-08-14T12:00:00.000Z';
const oudste = await logAiToolCall(regel({ occurredAt: '2026-08-14T11:00:00.000Z' }));
const midden = await logAiToolCall(regel({ occurredAt: t }));
const nieuwste = await logAiToolCall(regel({ occurredAt: '2026-08-14T13:00:00.000Z' }));
assert.deepEqual(listAiToolCalls().map(r => r.id), [nieuwste, midden, oudste]);
});
it('klemt de limiet en valt terug op de SSOT-waarde', async () => {
for (let i = 0; i < 5; i++) await logAiToolCall(regel());
assert.equal(listAiToolCalls(3).length, 3);
assert.equal(listAiToolCalls(0).length, 5, '0 is geen geldige limiet, dus de standaard geldt');
assert.equal(listAiToolCalls('onzin').length, 5);
assert.ok(CONFIG.AI.AUDIT_PAGE_SIZE >= 5, 'anders meet deze test iets anders dan hij bedoelt');
});
it('overleeft onleesbare inhoud in fieldsShared', async () => {
harness.exports.run(
`INSERT INTO AiToolCall (sessionId, tool, fieldsShared, outcome, occurredAt)
VALUES ('s','t','{geen json','ok','2026-08-14T12:00:00.000Z')`);
const [r] = listAiToolCalls();
assert.deepEqual(r.fieldsShared, [], 'geen exception, maar een lege lijst');
});
it('telt per uitkomst, en noemt elke soort ook als hij nul is', async () => {
await logAiToolCall(regel({ outcome: 'ok' }));
await logAiToolCall(regel({ outcome: 'refused' }));
const telling = countAiToolCalls();
assert.equal(telling.total, 2);
assert.equal(telling.byOutcome.ok, 1);
assert.equal(telling.byOutcome.refused, 1);
assert.equal(telling.byOutcome.error, 0, 'een soort die niet voorkomt is 0 en niet afwezig');
});
it('geeft op een leeg logboek nul en geen null', () => {
assert.deepEqual(countAiToolCalls(), { total: 0, byOutcome: { ok: 0, error: 0, refused: 0 } });
assert.equal(lastAiToolCallAt(), null);
});
it('noemt het moment van de laatste aanroep', async () => {
await logAiToolCall(regel({ occurredAt: '2026-08-14T12:00:00.000Z' }));
await logAiToolCall(regel({ occurredAt: '2026-08-14T13:00:00.000Z' }));
assert.equal(lastAiToolCallAt(), '2026-08-14T13:00:00.000Z');
});
});
const gesprek = (extra = {}) => ({ kind: 'request', text: '{"note":"hallo"}', ...extra });
describe('ai-audit: het gesprek vastleggen', () => {
it('legt de momenten vast bij de aanroep waar ze bij horen', async () => {
const id = await logAiToolCall(regel({
messages: [
gesprek({ occurredAt: '2026-08-17T10:00:00.000Z' }),
gesprek({ kind: 'response', text: 'Verbinding werkt.', occurredAt: '2026-08-17T10:00:01.000Z' }),
],
}));
const m = aiToolCallMessages(id);
assert.equal(m.length, 2);
assert.deepEqual(m.map(x => x.kind), ['request', 'response'], 'op volgorde van gebeuren');
assert.equal(m[1].text, 'Verbinding werkt.');
assert.equal(m[0].callId, id);
assert.equal(m[0].occurredAt, '2026-08-17T10:00:00.000Z');
});
it('houdt de volgorde ook als beide momenten in dezelfde milliseconde vallen', async () => {
const t = '2026-08-17T10:00:00.000Z';
const id = await logAiToolCall(regel({
messages: [gesprek({ occurredAt: t }), gesprek({ kind: 'response', text: 'x', occurredAt: t })],
}));
assert.deepEqual(aiToolCallMessages(id).map(x => x.kind), ['request', 'response']);
});
it('laat een aanroep zonder gesprek gewoon bestaan', async () => {
const id = await logAiToolCall(regel());
assert.deepEqual(aiToolCallMessages(id), []);
});
it('weigert een verzonnen moment, en laat dan ook de auditregel niet half achter', async () => {
await assert.rejects(
() => logAiToolCall(regel({ messages: [gesprek({ kind: 'antwoord' })] })), /Invalid kind/);
assert.equal(listAiToolCalls().length, 0, 'geen halve aanroep in het logboek');
});
it('kapt een te lange tekst af en zegt dat er afgekapt is', async () => {
const grens = CONFIG.AI.LIMITS.MAX_LOG_CHARS;
const id = await logAiToolCall(regel({
messages: [gesprek({ kind: 'response', text: 'x'.repeat(grens + 500) })],
}));
const [m] = aiToolCallMessages(id);
assert.ok(m.text.length <= grens, `bewaard: ${m.text.length}, grens: ${grens}`);
assert.match(m.text, /Afgekapt/, 'en dát staat er zichtbaar bij');
});
it('bewaart een leeg gesprek als leeg en niet als ontbrekend', async () => {
const id = await logAiToolCall(regel({ messages: [gesprek({ text: '' })] }));
assert.equal(aiToolCallMessages(id).length, 1);
assert.equal(aiToolCallMessages(id)[0].text, '');
});
it('geeft een lege lijst bij een aanroep die niet bestaat', async () => {
await logAiToolCall(regel({ messages: [gesprek()] }));
assert.deepEqual(aiToolCallMessages(9999), [], 'geen exception op een onbekend id');
assert.deepEqual(aiToolCallMessages(null), []);
assert.deepEqual(aiToolCallMessages('onzin'), []);
});
it('houdt de gesprekken van twee aanroepen uit elkaar', async () => {
const een = await logAiToolCall(regel({ messages: [gesprek({ text: 'eerste' })] }));
const twee = await logAiToolCall(regel({ messages: [gesprek({ text: 'tweede' })] }));
assert.deepEqual(aiToolCallMessages(een).map(m => m.text), ['eerste']);
assert.deepEqual(aiToolCallMessages(twee).map(m => m.text), ['tweede']);
});
});
describe('ai-audit: laten weten dát er iets gebeurd is', () => {
it('zet een verandersignaal bij elke vastgelegde aanroep', async () => {
await logAiToolCall(regel());
assert.deepEqual(takeChanges(), ['ai-activity-changed']);
});
it('zet dat signaal óók bij een geweigerde aanroep', async () => {
await logAiToolCall(regel({ outcome: 'refused', detail: 'buiten scope' }));
assert.deepEqual(takeChanges(), ['ai-activity-changed']);
});
it('zet geen signaal als de regel geweigerd wordt', async () => {
await assert.rejects(() => logAiToolCall(regel({ outcome: 'geslaagd' })));
assert.deepEqual(takeChanges(), []);
});
it('telt hoeveel aanroepen er ná een gezien id zijn, mét het hoogste id van nu', async () => {
const een = await logAiToolCall(regel());
const twee = await logAiToolCall(regel());
assert.deepEqual(countAiToolCallsSince(een), { count: 1, newestId: twee });
assert.deepEqual(countAiToolCallsSince(twee), { count: 0, newestId: twee },
'alles gezien, maar het id komt wél mee');
});
it('rekent zonder gezien id alles als nieuw', async () => {
await logAiToolCall(regel());
await logAiToolCall(regel());
assert.equal(countAiToolCallsSince(0).count, 2);
assert.equal(countAiToolCallsSince(null).count, 2, 'nooit gekeken leest als 0');
assert.equal(countAiToolCallsSince('onzin').count, 2);
});
it('geeft op een leeg logboek nul en geen null', async () => {
assert.deepEqual(countAiToolCallsSince(0), { count: 0, newestId: 0 });
});
});
+256
View File
@@ -0,0 +1,256 @@
// Generated, do not edit. sha256:e96cfe6ba81097a2ecb4810acd03529886647af210d2bac0f57c242345f7c633
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { createAiProposal, listPendingAiProposals, listAiProposals, getAiProposal, acceptAiProposal,
rejectAiProposal } = await import('../../server/data/ai-proposals.js');
const { createAccount } = await import('../../server/data/accounts.js');
const { listEvents } = await import('../../server/data/events.js');
const { createAsset } = await import('../../server/data/assets.js');
const { listPriceHistory } = await import('../../server/data/price-history.js');
const { takeChanges } = await import('../../server/data/_events.js');
const id = r => (typeof r === 'object' && r !== null ? (r.id ?? r) : r);
let btc, acc;
function manualProvider() {
harness.exports.run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES ('manual','Handmatig','handmatig','','none',0,1,1,1,'2026-01-01T00:00:00Z')`);
}
const voorstel = (extra = {}) => createAiProposal({
kind: 'price_point', assetId: btc, fiatPrice: 50000, observedAt: '2026-03-01T00:00:00Z',
note: 'koers volgens de muntwinkel', ...extra,
});
beforeEach(async () => {
harness.recreate();
takeChanges();
btc = id(await createAsset({ symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'unit' }));
acc = id(await createAccount({ name: 'Kluis', kind: 'vault', isOwnCustody: 1, scanEnabled: 0 }));
takeChanges();
});
describe('ai-proposals: aanmaken en opsommen', () => {
it('maakt een voorstel dat openstaat en de asset erbij noemt', async () => {
const pid = await voorstel();
const rij = getAiProposal(pid);
assert.equal(rij.status, 'pending');
assert.equal(rij.asset_symbol, 'BTC');
assert.equal(rij.asset_name, 'Bitcoin');
assert.equal(rij.resolvedAt, null);
});
it('weigert een onbekende soort met een leesbare fout', async () => {
await assert.rejects(() => createAiProposal({ kind: 'iets_anders' }), /kind/);
});
it('somt alleen openstaande voorstellen op', async () => {
const blijft = await voorstel();
const weg = await voorstel({ fiatPrice: 60000 });
await rejectAiProposal(weg, 'niet nodig');
assert.deepEqual(listPendingAiProposals().map(r => r.id), [blijft]);
});
});
describe('ai-proposals: accepteren voert écht uit', () => {
it('schrijft het koerspunt via de datalaag naar PriceHistory', async () => {
manualProvider();
const pid = await voorstel();
await acceptAiProposal(pid);
const punten = listPriceHistory(btc);
assert.equal(punten.length, 1, 'het punt hoort in PriceHistory te staan');
assert.equal(punten[0].fiatPrice, 50000);
assert.equal(punten[0].observedAt, '2026-03-01T00:00:00Z', 'dagresolutie, dus middernacht');
assert.equal(punten[0].resolution, 'day');
assert.equal(getAiProposal(pid).status, 'accepted');
assert.ok(getAiProposal(pid).resolvedAt, 'resolvedAt hoort gevuld te zijn');
});
it('kan twee keer toegepast worden zonder dubbel punt, want de upsert is idempotent', async () => {
manualProvider();
const eerste = await voorstel();
await acceptAiProposal(eerste);
const tweede = await voorstel();
await acceptAiProposal(tweede);
assert.equal(listPriceHistory(btc).length, 1, 'zelfde asset, zelfde dag, zelfde bron: één rij');
});
});
describe('ai-proposals: de niet-gelukkige paden', () => {
it('weigert een tweede keer accepteren', async () => {
manualProvider();
const pid = await voorstel();
await acceptAiProposal(pid);
await assert.rejects(() => acceptAiProposal(pid), (e) => e.key === 'aiProposals.handled_accepted');
});
it('weigert accepteren van een afgewezen voorstel', async () => {
const pid = await voorstel();
await rejectAiProposal(pid);
await assert.rejects(() => acceptAiProposal(pid), (e) => e.key === 'aiProposals.handled_rejected');
assert.equal(listPriceHistory(btc).length, 0, 'er hoort niets geschreven te zijn');
});
it('weigert een voorstel dat niet bestaat', async () => {
await assert.rejects(() => acceptAiProposal(999999), (e) => e.key === 'aiProposals.unknownProposal');
await assert.rejects(() => rejectAiProposal(999999), (e) => e.key === 'aiProposals.unknownProposal');
});
it('verdwijnt met zijn asset, en accepteren gooit daarna', async () => {
const pid = await voorstel();
harness.exports.run(`DELETE FROM Asset WHERE id = ?`, [btc]);
assert.equal(getAiProposal(pid), null);
await assert.rejects(() => acceptAiProposal(pid), (e) => e.key === 'aiProposals.unknownProposal');
});
it('laat het voorstel STAAN als de uitvoering afketst, met de reden erbij', async () => {
const pid = await voorstel();
await assert.rejects(() => acceptAiProposal(pid), /manual/);
const rij = getAiProposal(pid);
assert.equal(rij.status, 'pending', 'een afgeketste uitvoering hoort het voorstel niet af te handelen');
assert.match(rij.detail, /manual/, 'de reden hoort na te lezen te zijn');
assert.deepEqual(listPendingAiProposals().map(r => r.id), [pid], 'en hij staat nog op de lijst');
});
it('wist de reden zodra het alsnog lukt', async () => {
const pid = await voorstel();
await assert.rejects(() => acceptAiProposal(pid));
manualProvider();
await acceptAiProposal(pid);
assert.equal(getAiProposal(pid).detail, null);
assert.equal(getAiProposal(pid).status, 'accepted');
});
it('bewaart een afgewezen voorstel in plaats van het te verwijderen', async () => {
const pid = await voorstel();
await rejectAiProposal(pid, 'die koers klopt niet');
const rij = getAiProposal(pid);
assert.equal(rij.status, 'rejected');
assert.equal(rij.detail, 'die koers klopt niet');
assert.ok(rij.resolvedAt);
});
});
describe('het verandersignaal, zodat de kaart zichzelf bijwerkt (27-08-2026)', () => {
it('zet een signaal bij het aanmaken', async () => {
await voorstel();
assert.deepEqual(takeChanges(), ['ai-proposals-changed']);
});
it('zet een signaal bij het afwijzen, voor een tweede tabblad en voor de bolletjes', async () => {
const pid = await voorstel();
takeChanges();
await rejectAiProposal(pid, 'niet nodig');
assert.deepEqual(takeChanges(), ['ai-proposals-changed']);
});
it('zet een signaal bij het goedkeuren', async () => {
manualProvider();
const pid = await voorstel();
takeChanges();
await acceptAiProposal(pid);
assert.ok(takeChanges().includes('ai-proposals-changed'));
});
it('zet OOK een signaal als de uitvoering afketst', async () => {
const pid = await voorstel();
takeChanges();
await assert.rejects(() => acceptAiProposal(pid));
assert.deepEqual(takeChanges(), ['ai-proposals-changed']);
});
it('zegt niets bij een leesactie', async () => {
await voorstel();
takeChanges();
listPendingAiProposals();
getAiProposal(1);
assert.deepEqual(takeChanges(), []);
});
});
describe('listAiProposals: ook wat al afgehandeld is (27-08-2026)', () => {
it('geeft afgewezen en openstaande voorstellen, nieuwste eerst', async () => {
const eerste = await voorstel();
const tweede = await voorstel({ fiatPrice: 60000 });
await rejectAiProposal(eerste, 'niet nodig');
const rijen = listAiProposals();
assert.deepEqual(rijen.map(r => r.id), [tweede, eerste], 'nieuwste eerst');
assert.equal(rijen.find(r => r.id === eerste).status, 'rejected', 'een afgehandeld voorstel hoort erin te zitten');
});
it('houdt zich aan de grens en negeert een onzinnige', async () => {
for (let i = 0; i < 4; i++) await voorstel({ fiatPrice: 50000 + i });
assert.equal(listAiProposals(2).length, 2);
assert.equal(listAiProposals(0).length, 4);
assert.equal(listAiProposals('twee').length, 4);
});
it('geeft NIETS als de koppeling uit staat, net als de pending-lijst', async () => {
await voorstel();
harness.exports.run(`UPDATE Preference SET value = '0' WHERE scope = 'ai' AND key = 'allow_read'`);
harness.exports.run(`UPDATE Preference SET value = '0' WHERE scope = 'ai' AND key = 'enabled'`);
assert.deepEqual(listAiProposals(), []);
});
});
describe('ai-proposals: kosten', () => {
const boeking = (extra = {}) => createAiProposal({
kind: 'event', assetId: btc, eventType: 'buy', amount: 100000, fiatPrice: 50000,
observedAt: '2026-03-01T10:00:00Z', toAccountId: acc, ...extra,
});
it('bewaart de kosten op het voorstel', async () => {
const pid = await boeking({ fiatFee: 4.5 });
assert.equal(getAiProposal(pid).fiatFee, 4.5);
});
it('laat ze weg als ze er niet zijn, en zet geen nul', async () => {
assert.equal(getAiProposal(await boeking()).fiatFee, null);
});
it('ZET ZE OOK ÉCHT OP DE BOEKING, en dat is de hele reden dat dit veld bestaat', async () => {
const pid = await boeking({ fiatFee: 4.5 });
await acceptAiProposal(pid);
const events = listEvents();
assert.equal(events.length, 1);
assert.equal(events[0].fiatFee, 4.5, 'zonder deze regel is de kostprijs precies de fee te laag');
assert.equal(events[0].fiatPrice, 50000, 'en de koers hoort onaangeroerd te blijven');
});
it('weigert kosten op een soort die ze niet kent', async () => {
await assert.rejects(
() => createAiProposal({ kind: 'price_point', assetId: btc, fiatPrice: 50000,
observedAt: '2026-03-01T00:00:00Z', fiatFee: 1 }),
/Fees belong to an event/);
});
it('weigert een negatieve fee, want dat is de DDL', async () => {
await assert.rejects(() => boeking({ fiatFee: -1 }));
});
const verkoop = (extra = {}) => boeking({ eventType: 'sell', toAccountId: null, fromAccountId: acc, ...extra });
it('zet een fee in eenheden op de verkoop die hij goedkeurt', async () => {
const pid = await verkoop({ unitFee: 150 });
assert.equal(getAiProposal(pid).unitFee, 150);
await acceptAiProposal(pid);
assert.equal(listEvents()[0].unitFee, 150, 'zonder deze regel staat het grootboek de fee te hoog');
});
it('weigert een fee in eenheden waar het grootboek hem niet leest', async () => {
await assert.rejects(() => boeking({ unitFee: 150 }), /fee in units belongs to a sell or a transfer/);
await assert.rejects(
() => createAiProposal({ kind: 'price_point', assetId: btc, fiatPrice: 50000,
observedAt: '2026-03-01T00:00:00Z', unitFee: 1 }),
/Fees belong to an event/);
});
});
+261
View File
@@ -0,0 +1,261 @@
// Generated, do not edit. sha256:3e601bf7d829d14e2bd02a09636bf79c397a27d8499910e51c05e4aa817aa93b
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
apiCallsByProvider, apiMetricsByCapability, apiMetricsGlobal, apiMetricsSeries, apiMetricsTotals, recordApiCall,
} = await import('../../server/data/api-metrics.js');
const VANAF = '2020-01-01T00:00';
let ctx;
beforeEach(() => {
harness.recreate();
ctx = { mempool: provider('mempool'), coingecko: provider('coingecko') };
});
function provider(key) {
harness.exports.run(
`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) VALUES (?,?,?,?,'none',0,1,0,0,'2024-01-01T00:00:00')`, [key, key, toSearchName(key), `https://${key}.test`]);
return harness.exports.lastId();
}
function bucket({ providerId = ctx.mempool, capability = 'spot', minute, ok = 1, fail = 0, sum = 100, max = 100 }) {
harness.exports.run(
`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs)
VALUES (?,?,?,?,?,?,?)`, [providerId, capability, minute, ok, fail, sum, max]);
}
const rijen = () => harness.exports.all(
`SELECT * FROM ApiCallMetric ORDER BY providerId, capability, bucketMinute`);
describe('recordApiCall', () => {
it('registreert een gelukte call in de bucket van deze minuut', async () => {
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 120 });
const r = rijen();
assert.equal(r.length, 1);
assert.equal(r[0].okCount, 1);
assert.equal(r[0].failCount, 0);
assert.equal(r[0].sumLatencyMs, 120);
assert.equal(r[0].maxLatencyMs, 120);
assert.match(r[0].bucketMinute, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/,
'de bucket is een minuut-prefix, want daarop wordt later per uur en dag gegroepeerd');
});
it('registreert een mislukte call apart', async () => {
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: false, latency_ms: 80 });
assert.equal(rijen()[0].okCount, 0);
assert.equal(rijen()[0].failCount, 1);
assert.equal(rijen()[0].sumLatencyMs, 80, 'ook een mislukte call heeft een latency');
});
it('telt op binnen dezelfde minuut in plaats van te overschrijven', async () => {
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 100 });
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 200 });
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: false, latency_ms: 50 });
const r = rijen();
assert.equal(r.length, 1, 'één rij per (provider, capability, minuut)');
assert.equal(r[0].okCount, 2);
assert.equal(r[0].failCount, 1);
assert.equal(r[0].sumLatencyMs, 350, 'de som telt op');
assert.equal(r[0].maxLatencyMs, 200, 'het maximum telt NIET op maar neemt het hoogste');
});
it('houdt capabilities en providers gescheiden', async () => {
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 10 });
await recordApiCall({ providerId: ctx.mempool, capability: 'daily_history', ok: true, latency_ms: 10 });
await recordApiCall({ providerId: ctx.coingecko, capability: 'spot', ok: true, latency_ms: 10 });
assert.equal(rijen().length, 3, 'de sleutel is (provider, capability, minuut)');
});
it('negeert een call zonder geldige provider', async () => {
await recordApiCall({ providerId: null, capability: 'spot', ok: true, latency_ms: 10 });
await recordApiCall({ providerId: undefined, capability: 'spot', ok: true, latency_ms: 10 });
await recordApiCall({ providerId: 'mempool', capability: 'spot', ok: true, latency_ms: 10 });
await recordApiCall({ providerId: 1.5, capability: 'spot', ok: true, latency_ms: 10 });
assert.deepEqual(rijen(), [], 'geen enkele rij, en ook geen fout');
});
it('maakt van een ontbrekende of onzinnige latency een 0', async () => {
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true });
assert.equal(rijen()[0].sumLatencyMs, 0, 'de kolom is NOT NULL, dus dit moet een getal zijn');
harness.recreate();
ctx.mempool = provider('mempool');
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: NaN });
assert.equal(rijen()[0].sumLatencyMs, 0);
});
it('rondt af en klemt een negatieve latency op 0', async () => {
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: 12.6 });
assert.equal(rijen()[0].sumLatencyMs, 13);
harness.recreate();
ctx.mempool = provider('mempool');
await recordApiCall({ providerId: ctx.mempool, capability: 'spot', ok: true, latency_ms: -5 });
assert.equal(rijen()[0].sumLatencyMs, 0, 'een negatieve duur is nooit een meting');
});
});
describe('apiMetricsTotals', () => {
it('telt over de buckets en leidt gemiddelde en maximum af', () => {
bucket({ minute: '2024-05-01T10:00', ok: 2, fail: 0, sum: 300, max: 200 });
bucket({ minute: '2024-05-01T10:01', ok: 1, fail: 1, sum: 100, max: 60 });
assert.deepEqual(apiMetricsTotals(ctx.mempool, VANAF), {
ok: 3, fail: 1, calls: 4, avg_latency_ms: 100, maxLatencyMs: 200,
});
});
it('geeft nullen als er niets is, zonder deling door nul', () => {
assert.deepEqual(apiMetricsTotals(ctx.mempool, VANAF), {
ok: 0, fail: 0, calls: 0, avg_latency_ms: 0, maxLatencyMs: 0,
});
});
it('negeert buckets vóór de grens', () => {
bucket({ minute: '2024-04-30T23:59', ok: 5, sum: 500, max: 500 });
bucket({ minute: '2024-05-01T00:00', ok: 1, sum: 100, max: 100 });
const t = apiMetricsTotals(ctx.mempool, '2024-05-01T00:00');
assert.equal(t.ok, 1, 'de grens is inclusief: >= sinceBucket');
assert.equal(t.maxLatencyMs, 100, 'ook het maximum kijkt alleen binnen het venster');
});
it('kijkt alleen naar de gevraagde provider', () => {
bucket({ providerId: ctx.mempool, minute: '2024-05-01T10:00', ok: 1 });
bucket({ providerId: ctx.coingecko, minute: '2024-05-01T10:00', ok: 7 });
assert.equal(apiMetricsTotals(ctx.mempool, VANAF).ok, 1);
assert.equal(apiMetricsTotals(ctx.coingecko, VANAF).ok, 7);
});
it('rondt het gemiddelde af op een heel getal', () => {
bucket({ minute: '2024-05-01T10:00', ok: 3, fail: 0, sum: 100, max: 50 });
assert.equal(apiMetricsTotals(ctx.mempool, VANAF).avg_latency_ms, 33);
});
});
describe('apiMetricsGlobal', () => {
it('telt over alle providers heen', () => {
bucket({ providerId: ctx.mempool, minute: '2024-05-01T10:00', ok: 2, fail: 1, sum: 300, max: 150 });
bucket({ providerId: ctx.coingecko, minute: '2024-05-01T10:00', ok: 1, fail: 0, sum: 100, max: 400 });
assert.deepEqual(apiMetricsGlobal(VANAF), {
ok: 3, fail: 1, calls: 4, avg_latency_ms: 100, maxLatencyMs: 400,
});
});
it('spiegelt de vorm van apiMetricsTotals', () => {
bucket({ minute: '2024-05-01T10:00', ok: 1, sum: 10, max: 10 });
assert.deepEqual(Object.keys(apiMetricsGlobal(VANAF)).sort(),
Object.keys(apiMetricsTotals(ctx.mempool, VANAF)).sort());
});
it('geeft nullen op een lege tabel', () => {
assert.deepEqual(apiMetricsGlobal(VANAF), {
ok: 0, fail: 0, calls: 0, avg_latency_ms: 0, maxLatencyMs: 0,
});
});
});
describe('apiCallsByProvider', () => {
it('telt gelukte en mislukte aanroepen samen, per provider, over alle capabilities', () => {
bucket({ providerId: ctx.mempool, capability: 'spot', minute: '2024-05-01T10:00', ok: 2, fail: 1 });
bucket({ providerId: ctx.mempool, capability: 'daily_history', minute: '2024-05-02T10:00', ok: 1, fail: 0 });
bucket({ providerId: ctx.coingecko, minute: '2024-05-01T10:00', ok: 0, fail: 4 });
const uit = new Map(apiCallsByProvider(VANAF).map(r => [r.providerId, r.calls]));
assert.equal(uit.get(ctx.mempool), 4);
assert.equal(uit.get(ctx.coingecko), 4, 'vier mislukte aanroepen kosten ook vier van het tegoed');
});
it('telt vorige maand niet mee', () => {
bucket({ minute: '2024-04-30T23:59', ok: 50 });
bucket({ minute: '2024-05-01T00:00', ok: 1 });
assert.deepEqual(apiCallsByProvider('2024-05-01T00:00').map(r => r.calls), [1]);
});
});
describe('apiMetricsByCapability', () => {
it('groepeert per capability, alfabetisch', () => {
bucket({ capability: 'spot', minute: '2024-05-01T10:00', ok: 1, sum: 100, max: 100 });
bucket({ capability: 'spot', minute: '2024-05-01T10:01', ok: 2, sum: 200, max: 150 });
bucket({ capability: 'daily_history', minute: '2024-05-01T10:00', ok: 1, fail: 2, sum: 90, max: 90 });
const r = apiMetricsByCapability(ctx.mempool, VANAF);
assert.deepEqual(r.map(x => x.capability), ['daily_history', 'spot'],
'vaste volgorde, zodat het uitklap-paneel niet danst tussen twee aanroepen');
assert.equal(r[0].fail, 2);
assert.equal(r[1].ok, 3);
assert.equal(r[1].max_lat, 150);
});
it('geeft een lege lijst zonder metingen', () => {
assert.deepEqual(apiMetricsByCapability(ctx.mempool, VANAF), []);
});
});
describe('apiMetricsSeries', () => {
beforeEach(() => {
bucket({ minute: '2024-05-01T10:00', ok: 1, sum: 10, max: 10 });
bucket({ minute: '2024-05-01T10:30', ok: 2, sum: 20, max: 40 });
bucket({ minute: '2024-05-01T11:00', ok: 1, fail: 1, sum: 30, max: 90 });
bucket({ minute: '2024-05-02T09:00', ok: 5, sum: 50, max: 20 });
});
it('groepeert per minuut bij prefixlengte 16', () => {
assert.equal(apiMetricsSeries(ctx.mempool, VANAF, 16).length, 4, 'elke bucket apart');
});
it('groepeert per uur bij prefixlengte 13', () => {
const r = apiMetricsSeries(ctx.mempool, VANAF, 13);
assert.deepEqual(r.map(x => x.b), ['2024-05-01T10', '2024-05-01T11', '2024-05-02T09']);
assert.equal(r[0].ok, 3, 'de twee buckets van 10:xx samen');
assert.equal(r[0].max_lat, 40, 'het maximum van dat uur, niet de som');
});
it('groepeert per dag bij prefixlengte 10', () => {
const r = apiMetricsSeries(ctx.mempool, VANAF, 10);
assert.deepEqual(r.map(x => x.b), ['2024-05-01', '2024-05-02']);
assert.equal(r[0].ok, 4);
assert.equal(r[0].fail, 1);
assert.equal(r[1].ok, 5);
});
it('loopt in chronologische volgorde', () => {
const b = apiMetricsSeries(ctx.mempool, VANAF, 13).map(x => x.b);
assert.deepEqual(b, [...b].sort(), 'een sparkline moet van links naar rechts vooruit lopen');
});
it('is sparse: buckets zonder calls komen er niet in', () => {
const uren = apiMetricsSeries(ctx.mempool, VANAF, 13).map(x => x.b);
assert.equal(uren.includes('2024-05-01T12'), false);
});
it('respecteert de ondergrens', () => {
assert.deepEqual(apiMetricsSeries(ctx.mempool, '2024-05-02T00:00', 10).map(x => x.b), ['2024-05-02']);
});
});
describe('randwaardes: apiMetricsSeries op de randen', () => {
beforeEach(() => {
bucket({ minute: '2024-05-01T10:00', ok: 1, sum: 10, max: 10 });
bucket({ minute: '2024-05-05T10:00', ok: 1, sum: 10, max: 10 });
});
it('geeft een lege lijst als er ná de grens niets meer is', () => {
assert.deepEqual(apiMetricsSeries(ctx.mempool, '2030-01-01T00:00', 10), []);
});
it('geeft exact één punt terug als er één bucket binnen de grens valt', () => {
const reeks = apiMetricsSeries(ctx.mempool, '2024-05-05T10:00', 10);
assert.equal(reeks.length, 1, 'één bucket blijft één punt; er wordt niets aangevuld');
assert.equal(reeks[0].b, '2024-05-05');
});
it('is sparse: de dagen tússen twee metingen komen niet in de reeks', () => {
const reeks = apiMetricsSeries(ctx.mempool, VANAF, 10);
assert.deepEqual(reeks.map(r => r.b), ['2024-05-01', '2024-05-05']);
});
});
+57
View File
@@ -0,0 +1,57 @@
// Generated, do not edit. sha256:e5fc151e66dc65fbc4a3b92e5607e9c11c93c4da41ceb4d39b4aa634c9cb82b3
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { toSearchName } from '../../shared/search-name.js';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { getAssetNote, setAssetNote } = await import('../../server/data/asset-notes.js');
let btc;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin',?,'crypto','BTC',NULL,8,'unit')`,
[toSearchName('Bitcoin')]);
btc = harness.exports.lastId();
});
describe('setAssetNote / getAssetNote', () => {
it('geeft null zonder notitie en de tekst na opslaan', async () => {
assert.equal(getAssetNote(btc), null);
const uit = await setAssetNote(btc, '2026-09-26 14:32 gekocht na de dip');
assert.equal(uit.body, '2026-09-26 14:32 gekocht na de dip');
assert.equal(getAssetNote(btc).body, uit.body);
assert.ok(uit.createdAt);
});
it('wijzigt de ene notitie en laat createdAt staan', async () => {
await setAssetNote(btc, 'eerste');
harness.exports.run(`UPDATE AssetNote SET createdAt = '2026-01-01T00:00:00.000Z'`);
const uit = await setAssetNote(btc, 'tweede');
assert.equal(uit.body, 'tweede');
assert.equal(uit.createdAt, '2026-01-01T00:00:00.000Z');
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 1, 'één notitie per asset');
});
it('haalt de rij weg bij een lege of alleen-witruimte notitie', async () => {
await setAssetNote(btc, 'iets');
assert.equal(await setAssetNote(btc, ' \n '), null);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 0);
await setAssetNote(btc, 'iets');
assert.equal(await setAssetNote(btc, null), null, 'geen tekst is leeg');
});
it('weigert een onbekend asset', async () => {
await assert.rejects(() => setAssetNote(btc + 99, 'x'), /does not exist/);
});
it('verdwijnt met zijn asset', async () => {
await setAssetNote(btc, 'weg ermee');
harness.exports.run(`DELETE FROM Asset WHERE id = ?`, [btc]);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM AssetNote`).n, 0);
});
});
+45
View File
@@ -0,0 +1,45 @@
// Generated, do not edit. sha256:841593fe1e857633d46e2fe3ce5552c3a7874ece2d6acdbc7160656fde1ef2c6
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { listAssets, listBookableAssets } = await import('../../server/data/assets.js');
const { run, lastId } = harness.exports;
function asset(symbol, kind) {
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?,?,?,?,?,'cent',2,'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]);
return lastId();
}
const symbolen = rijen => rijen.map(a => a.symbol);
let usd;
beforeEach(() => {
harness.recreate();
asset('BTC', 'crypto');
asset('EUR', 'fiat');
usd = asset('USD', 'fiat');
});
describe('listBookableAssets', () => {
it('laat een ongebruikte vreemde munt weg, en de euro en bitcoin niet', () => {
assert.deepEqual(symbolen(listBookableAssets()), ['BTC', 'EUR']);
assert.deepEqual(symbolen(listAssets()), ['BTC', 'EUR', 'USD'], 'listAssets blijft alles geven');
});
it('toont de munt zodra er een boeking in staat', () => {
run(`INSERT INTO Event (assetId, amount, type, source, occurredAt, createdAt)
VALUES (?, 100, 'transfer', 'manual', '2026-09-01T10:00:00Z', '2026-09-01T10:00:00Z')`, [usd]);
assert.deepEqual(symbolen(listBookableAssets()), ['BTC', 'EUR', 'USD']);
});
it('toont de munt zodra er een balans in staat', () => {
run(`INSERT INTO Valuation (assetId, balance, fiatRate, source, valuedOn, rateAt)
VALUES (?, 100, 0.9, 'manual', '2026-09-01', '2026-09-01T00:00:00Z')`, [usd]);
assert.deepEqual(symbolen(listBookableAssets()), ['BTC', 'EUR', 'USD']);
});
});
+172
View File
@@ -0,0 +1,172 @@
// Generated, do not edit. sha256:a27e8c58c9aae75ab07e5381309139cb5309819db52788dbf42ff055ad51599e
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { flushChanges, changesSince, pruneChanges } = await import('../../server/data/bus.js');
const { emitChange } = await import('../../server/data/_events.js');
const { CONFIG } = await import('../../shared/config.js');
const rijen = () => harness.exports.all(`SELECT id, name, occurredAt FROM BusSignal ORDER BY id`);
const oudeRij = (naam, urenGeleden) => harness.exports.run(
`INSERT INTO BusSignal (name, occurredAt) VALUES (?,?)`,
[naam, new Date(Date.now() - urenGeleden * 3600_000).toISOString()]);
beforeEach(() => {
harness.recreate();
flushChanges();
});
describe('bus: het mandje wegschrijven', () => {
it('schrijft elke naam uit het mandje naar de tabel', async () => {
emitChange('providers-changed');
emitChange('connections-changed');
const namen = flushChanges();
assert.deepEqual(namen.sort(), ['connections-changed', 'providers-changed']);
assert.deepEqual(rijen().map(r => r.name).sort(), ['connections-changed', 'providers-changed']);
assert.ok(rijen()[0].occurredAt, 'en er staat een moment bij');
});
it('voegt hetzelfde signaal binnen één eenheid werk samen', async () => {
emitChange('providers-changed');
emitChange('providers-changed');
emitChange('providers-changed');
flushChanges();
assert.equal(rijen().length, 1);
});
it('maakt het mandje leeg, zodat een tweede flush niets dubbel schrijft', async () => {
emitChange('providers-changed');
flushChanges();
assert.deepEqual(flushChanges(), [], 'niets meer over');
assert.equal(rijen().length, 1);
});
it('doet niets bij een leeg mandje, en schrijft dus ook geen lege rij', async () => {
assert.deepEqual(flushChanges(), []);
assert.equal(rijen().length, 0);
});
it('GOOIT NOOIT WEG: twee opeenvolgende flushes bewaren allebei hun signaal', async () => {
emitChange('eerst-dit');
flushChanges();
emitChange('daarna-dit');
flushChanges();
assert.deepEqual(rijen().map(r => r.name), ['eerst-dit', 'daarna-dit'], 'allebei, op volgorde');
});
});
describe('bus: teruglezen sinds een cursor', () => {
it('geeft bij een nieuwe client alleen de stand en geen historie', async () => {
emitChange('oud-nieuws');
flushChanges();
const r = changesSince(null);
assert.deepEqual(r.names, []);
assert.equal(r.cursor, 1, 'wél de stand, zodat de volgende poll wél iets kan opleveren');
assert.equal(r.reset, false);
});
it('geeft alleen wat er ná de cursor bij kwam', async () => {
emitChange('eerste');
flushChanges();
const na1 = changesSince(null).cursor;
emitChange('tweede');
flushChanges();
const r = changesSince(na1);
assert.deepEqual(r.names, ['tweede']);
assert.equal(r.cursor, 2);
});
it('ontdubbelt de namen, want een prikkel is inhoudsloos', async () => {
emitChange('providers-changed');
flushChanges();
emitChange('providers-changed');
flushChanges();
assert.deepEqual(changesSince(0).names, ['providers-changed'], 'twee rijen, één naam');
});
it('geeft niets terug als er niets nieuws is, en houdt de cursor gelijk', async () => {
emitChange('iets');
flushChanges();
const r = changesSince(1);
assert.deepEqual(r.names, []);
assert.equal(r.cursor, 1);
});
it('zegt "ververs alles" als de cursor achterloopt op de oudste bewaarde rij', async () => {
for (const naam of ['een', 'twee', 'drie']) { emitChange(naam); flushChanges(); }
harness.exports.run(`DELETE FROM BusSignal WHERE id <= 2`);
const r = changesSince(0);
assert.equal(r.reset, true);
assert.deepEqual(r.names, [], 'en géén halve lijst erbij, die zou suggereren dat het daarbij blijft');
assert.equal(r.cursor, 3);
});
it('meldt geen reset als de cursor precies aansluit op de oudste rij', async () => {
for (const naam of ['een', 'twee', 'drie']) { emitChange(naam); flushChanges(); }
harness.exports.run(`DELETE FROM BusSignal WHERE id <= 2`);
const r = changesSince(2);
assert.equal(r.reset, false);
assert.deepEqual(r.names, ['drie']);
});
it('behandelt onzin als een nieuwe client in plaats van om te vallen', async () => {
emitChange('iets');
flushChanges();
for (const raar of ['onzin', -1, 1.5, undefined, {}]) {
const r = changesSince(raar);
assert.deepEqual(r.names, [], `${JSON.stringify(raar)} hoort als "ik begin nu" te lezen`);
assert.equal(r.reset, false);
}
});
it('geeft op een lege bus een cursor van nul en geen null', async () => {
const r = changesSince(null);
assert.deepEqual(r, { cursor: 0, names: [], reset: false });
});
});
describe('bus: opruimen', () => {
it('gooit weg wat ouder is dan de bewaartermijn en laat de rest staan', async () => {
oudeRij('van-gisteren', CONFIG.BUS.RETENTION_HOURS + 1);
oudeRij('van-net', 1);
assert.equal(pruneChanges(), 1, 'geeft terug hoeveel er weg zijn');
assert.deepEqual(rijen().map(r => r.name), ['van-net']);
});
it('laat een rij die binnen de termijn valt staan', async () => {
oudeRij('net-binnen', CONFIG.BUS.RETENTION_HOURS - 1);
assert.equal(pruneChanges(), 0);
assert.equal(rijen().length, 1);
});
it('doet niets op een lege bus', async () => {
assert.equal(pruneChanges(), 0);
});
it('valt terug op de SSOT-waarde bij onzin', async () => {
oudeRij('oud', CONFIG.BUS.RETENTION_HOURS + 2);
assert.equal(pruneChanges('onzin'), 1);
});
it('ruimt op tijdens het wegschrijven, zonder dat de aanroeper er iets voor doet', async () => {
oudeRij('van-gisteren', CONFIG.BUS.RETENTION_HOURS + 1);
emitChange('nieuw');
flushChanges();
assert.deepEqual(rijen().map(r => r.name), ['nieuw'], 'de oude rij is meegenomen');
});
it('ruimt bij élke schrijfactie op, ook de tweede', async () => {
emitChange('eerste');
flushChanges();
oudeRij('van-gisteren', CONFIG.BUS.RETENTION_HOURS + 1);
emitChange('tweede');
flushChanges();
assert.equal(rijen().some(r => r.name === 'van-gisteren'), false, 'ook nu meegenomen');
});
});
+191
View File
@@ -0,0 +1,191 @@
// Generated, do not edit. sha256:df7d96413775b40f968338398229b7ff06e570855f37d25616828524e6f0575d
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { trackUpdate, trackDelete, trackUpdateMany, withOrigin, currentOrigin, rowHistory } =
await import('../../server/data/change-log.js');
const db = harness.exports;
function account(naam = 'Kluis') {
db.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive)
VALUES (?, ?, 'vault', 1, 0, 1)`, [naam, naam.toLowerCase()]);
return db.lastId();
}
const logboek = () => db.all(`SELECT * FROM ChangeLog ORDER BY id`);
beforeEach(() => harness.recreate());
describe('trackUpdate: wat er veranderde', () => {
it('schrijft één regel met alleen de gewijzigde velden', () => {
const id = account();
trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = ? WHERE id = ?`, ['Nieuwe naam', id]));
const regels = logboek();
assert.equal(regels.length, 1);
assert.equal(regels[0].tableName, 'Account');
assert.equal(regels[0].rowId, id);
assert.equal(regels[0].operation, 'update');
assert.deepEqual(JSON.parse(regels[0].changes), { name: ['Kluis', 'Nieuwe naam'] },
'alleen wat er veranderde, met oud én nieuw');
});
it('zet updatedAt, en meldt dat niet in zijn eigen regel', () => {
const id = account();
assert.equal(db.one(`SELECT updatedAt AS u FROM Account WHERE id = ?`, [id]).u, null);
trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Anders' WHERE id = ?`, [id]));
assert.ok(db.one(`SELECT updatedAt AS u FROM Account WHERE id = ?`, [id]).u, 'updatedAt hoort gezet');
assert.deepEqual(Object.keys(JSON.parse(logboek()[0].changes)), ['name'],
'updatedAt is boekhouding en geen antwoord op een vraag');
});
it('meldt updatedAt ook niet als de aanroeper hem ZÉLF zet', () => {
const id = account();
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET name = 'X', updatedAt = '2020-01-01T00:00:00Z' WHERE id = ?`, [id]));
assert.deepEqual(Object.keys(JSON.parse(logboek()[0].changes)), ['name'],
'het tijdstempel is boekhouding, ook als de aanroeper hem zelf zette');
});
it('schrijft NIETS als er niets veranderde', () => {
const id = account();
trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Kluis' WHERE id = ?`, [id]));
assert.deepEqual(logboek(), []);
assert.equal(db.one(`SELECT updatedAt AS u FROM Account WHERE id = ?`, [id]).u, null,
'en dan hoort updatedAt ook niet op te schuiven');
});
it('meldt meerdere velden in één regel', () => {
const id = account();
trackUpdate('Account', id, () =>
db.run(`UPDATE Account SET name = 'X', kind = 'bank' WHERE id = ?`, [id]));
const regels = logboek();
assert.equal(regels.length, 1, 'één handeling is één regel, ook als er twee velden veranderen');
assert.deepEqual(JSON.parse(regels[0].changes), { name: ['Kluis', 'X'], kind: ['vault', 'bank'] });
});
it('laat een tabel die niet meedoet met rust', () => {
trackUpdate('Preference', 1, () => db.run(`UPDATE Preference SET value = '"light"' WHERE key = 'theme'`));
assert.deepEqual(logboek(), []);
});
});
describe('trackDelete: wat er verdween', () => {
it('legt de hele rij vast en niet een verschil', () => {
const id = account('Weg hiermee');
trackDelete('Account', id, () => db.run(`DELETE FROM Account WHERE id = ?`, [id]));
const regels = logboek();
assert.equal(regels.length, 1);
assert.equal(regels[0].operation, 'delete');
const rij = JSON.parse(regels[0].changes);
assert.equal(rij.name, 'Weg hiermee', 'bij een delete bestaat er geen nieuw: wat er verdween ÍS de rij');
assert.equal(rij.kind, 'vault');
});
it('doet niets als de rij niet bestond', () => {
trackDelete('Account', 999, () => db.run(`DELETE FROM Account WHERE id = 999`));
assert.deepEqual(logboek(), []);
});
});
describe('trackUpdateMany: één regel per geraakte rij', () => {
it('schrijft per rij en niet per ronde', () => {
const a = account('Een'), b = account('Twee');
trackUpdateMany('Account', [a, b], () => db.run(`UPDATE Account SET kind = 'bank' WHERE id IN (?, ?)`, [a, b]));
const regels = logboek();
assert.equal(regels.length, 2, 'anders is "wat is er met DEZE rij gebeurd" onbeantwoordbaar');
assert.deepEqual(regels.map(r => r.rowId), [a, b]);
for (const r of regels) assert.deepEqual(JSON.parse(r.changes).kind, ['vault', 'bank']);
});
it('slaat de rijen over die niet veranderden', () => {
const a = account('Een'), b = account('Twee');
trackUpdateMany('Account', [a, b], () => db.run(`UPDATE Account SET kind = 'bank' WHERE id = ?`, [a]));
assert.deepEqual(logboek().map(r => r.rowId), [a]);
});
});
describe('withOrigin: door wie', () => {
it('staat standaard op handmatig', () => {
const id = account();
trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'X' WHERE id = ?`, [id]));
assert.equal(logboek()[0].origin, 'manual');
assert.equal(logboek()[0].originId, null, 'een handmatige wijziging heeft geen rij die hem aanwijst');
});
it('legt de herkomst en het voorstel vast', () => {
const id = account();
withOrigin('ai_proposal', 42, () =>
trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'X' WHERE id = ?`, [id])));
assert.equal(logboek()[0].origin, 'ai_proposal');
assert.equal(logboek()[0].originId, 42);
});
it('herstelt de vorige herkomst, ook als het misgaat', () => {
assert.throws(() => withOrigin('import', 7, () => { throw new Error('mislukt'); }), /mislukt/);
assert.deepEqual(currentOrigin(), { origin: 'manual', originId: null });
});
it('weigert een herkomst die niet in de enum staat', () => {
assert.throws(() => withOrigin('zomaar', null, () => {}), /Unknown origin/);
});
});
describe('de datalaag schrijft echt mee', () => {
it('legt een gewijzigde rekening vast, via het echte domeinpad', async () => {
const { createAccount, updateAccount } = await import('../../server/data/accounts.js');
const id = await createAccount({ name: 'Beurs', kind: 'exchange', isOwnCustody: 0, scanEnabled: 0 });
assert.deepEqual(logboek(), [], 'aanmaken levert geen regel op: dat staat in createdAt');
await updateAccount(id, { name: 'Andere beurs', kind: 'exchange', isOwnCustody: 0, scanEnabled: 0 });
const regels = logboek();
assert.equal(regels.length, 1);
assert.equal(regels[0].tableName, 'Account');
assert.equal(regels[0].rowId, id);
assert.deepEqual(JSON.parse(regels[0].changes).name, ['Beurs', 'Andere beurs']);
});
it('legt een verwijderde rekening vast met zijn hele rij', async () => {
const { createAccount, deleteAccount } = await import('../../server/data/accounts.js');
const id = await createAccount({ name: 'Weg', kind: 'vault', isOwnCustody: 1, scanEnabled: 0 });
await deleteAccount(id);
const regels = logboek();
assert.equal(regels.length, 1);
assert.equal(regels[0].operation, 'delete');
assert.equal(JSON.parse(regels[0].changes).name, 'Weg');
});
});
describe('rowHistory: de leeskant', () => {
it('geeft de wijzigingen van één rij, nieuwste eerst', () => {
const id = account();
trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Tweede' WHERE id = ?`, [id]));
trackUpdate('Account', id, () => db.run(`UPDATE Account SET name = 'Derde' WHERE id = ?`, [id]));
const h = rowHistory('Account', id);
assert.equal(h.length, 2);
assert.deepEqual(h[0].changes.name, ['Tweede', 'Derde'], 'nieuwste eerst');
assert.deepEqual(h[1].changes.name, ['Kluis', 'Tweede']);
});
it('geeft niets terug voor een rij zonder geschiedenis', () => {
assert.deepEqual(rowHistory('Account', account()), []);
});
});
+200
View File
@@ -0,0 +1,200 @@
// Generated, do not edit. sha256:dd655ee8d8d39adcd4b3b77241ca06552db9cdf6cc3583a38396eaddd1252282
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { createCommand, claimNextCommand, resolveCommand, getCommand, pruneCommands } =
await import('../../server/data/commands.js');
const { takeChanges } = await import('../../server/data/_events.js');
const { CONFIG } = await import('../../shared/config.js');
const straks = (seconden = 60) => new Date(Date.now() + seconden * 1000).toISOString();
const zoëven = (seconden = 60) => new Date(Date.now() - seconden * 1000).toISOString();
const doel = { route: 'system', tab: 'ai' };
beforeEach(() => { harness.recreate(); takeChanges(); });
describe('commands: klaarzetten', () => {
it('legt een opdracht neer als open, met zijn lading', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
const c = getCommand(id);
assert.equal(c.state, 'open');
assert.deepEqual(c.payload, doel, 'de lading komt als object terug, niet als JSON-tekst');
assert.equal(c.claimedBy, null);
assert.equal(c.resolvedAt, null);
});
it('meldt op de bus dát er iets klaarstaat, zonder te zeggen wát', async () => {
await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
assert.deepEqual(takeChanges(), ['commands-changed']);
});
it('weigert een verzonnen soort, en legt dan niets neer', async () => {
await assert.rejects(() => createCommand({ kind: 'zweef', payload: doel }), /Invalid kind/);
assert.equal(await claimNextCommand('tab-1'), null, 'er staat niets klaar');
assert.deepEqual(takeChanges(), [], 'en er gaat ook geen signaal uit');
});
});
describe('commands: het claim-model', () => {
it('geeft de opdracht aan het tabblad dat hem als eerste pakt', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
const eerste = await claimNextCommand('tab-1');
assert.equal(eerste.id, id);
assert.equal(eerste.state, 'claimed');
assert.equal(eerste.claimedBy, 'tab-1');
});
it('GEEFT HEM NIET AAN HET TWEEDE TABBLAD, en dat is de kern van deze fase', async () => {
await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
const eerste = await claimNextCommand('tab-1');
const tweede = await claimNextCommand('tab-2');
assert.ok(eerste);
assert.equal(tweede, null, 'de tweede krijgt niets en weet dus dat een ander hem heeft');
});
it('geeft null als er niets klaarstaat', async () => {
assert.equal(await claimNextCommand('tab-1'), null);
});
it('pakt de oudste eerst', async () => {
const een = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
assert.equal((await claimNextCommand('tab-1')).id, een);
});
it('weigert een claim zonder tabblad in plaats van hem aan niemand toe te wijzen', async () => {
await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await assert.rejects(() => claimNextCommand(''), /without a tab/);
});
});
describe('commands: claimen op soort', () => {
it('LAAT LIGGEN WAT DIT TABBLAD NIET KAN, en dat is de reden dat de filter bestaat', async () => {
const id = await createCommand({ kind: 'locate' });
assert.equal(await claimNextCommand('tab-1', ['navigate']), null, 'niet opgepakt');
assert.equal(getCommand(id).state, 'open', 'en ook niet afgewezen: hij blijft gewoon staan');
});
it('slaat een soort over en pakt de eerstvolgende die hij wél kent', async () => {
await createCommand({ kind: 'locate' });
const kan = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
assert.equal((await claimNextCommand('tab-1', ['navigate'])).id, kan);
});
it('pakt zonder filter nog steeds alles', async () => {
const id = await createCommand({ kind: 'locate' });
assert.equal((await claimNextCommand('tab-1')).id, id);
});
it('weigert een verzonnen soort en een lege lijst', async () => {
await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await assert.rejects(() => claimNextCommand('tab-1', ['zweef']), /Invalid kind/);
await assert.rejects(() => claimNextCommand('tab-1', []), /zero kinds/);
});
});
describe('commands: verlopen', () => {
it('pakt een verlopen opdracht niet op, maar sluit hem af', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: zoëven() });
assert.equal(await claimNextCommand('tab-1'), null);
const c = getCommand(id);
assert.equal(c.state, 'expired');
assert.ok(c.resolvedAt, 'en hij is afgehandeld, niet blijven hangen');
assert.match(c.detail, /op tijd/);
});
it('laat een opdracht zonder verlooptijd staan', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel });
const c = await claimNextCommand('tab-1');
assert.equal(c.id, id, 'hij is gewoon op te pakken');
});
});
describe('commands: afsluiten', () => {
it('rondt af als uitgevoerd', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await claimNextCommand('tab-1');
await resolveCommand(id, 'tab-1', 'done');
const c = getCommand(id);
assert.equal(c.state, 'done');
assert.ok(c.resolvedAt);
});
it('rondt af als tegengehouden, met de reden erbij', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await claimNextCommand('tab-1');
await resolveCommand(id, 'tab-1', 'rejected', 'Gestopt door de gebruiker.');
const c = getCommand(id);
assert.equal(c.state, 'rejected');
assert.equal(c.detail, 'Gestopt door de gebruiker.');
});
it('LAAT EEN ANDER TABBLAD HEM NIET AFSLUITEN', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await claimNextCommand('tab-1');
await resolveCommand(id, 'tab-2', 'done');
assert.equal(getCommand(id).state, 'claimed', 'onaangeroerd');
});
it('sluit een al afgesloten opdracht niet nog eens af', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await claimNextCommand('tab-1');
await resolveCommand(id, 'tab-1', 'done');
await resolveCommand(id, 'tab-1', 'rejected', 'te laat');
assert.equal(getCommand(id).state, 'done', 'de eerste uitkomst blijft staan');
});
it('weigert een uitkomst die geen afronding is', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await claimNextCommand('tab-1');
await assert.rejects(() => resolveCommand(id, 'tab-1', 'open'), /not a conclusion/);
await assert.rejects(() => resolveCommand(id, 'tab-1', 'claimed'), /not a conclusion/);
});
it('weigert een verzonnen uitkomst', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel, expiresAt: straks() });
await claimNextCommand('tab-1');
await assert.rejects(() => resolveCommand(id, 'tab-1', 'weggewaaid'), /Invalid state/);
});
});
describe('commands: opruimen', () => {
const verouder = (id) => harness.exports.run(`UPDATE Command SET resolvedAt = ? WHERE id = ?`,
[new Date(Date.now() - (CONFIG.COMMANDS.RETENTION_HOURS + 1) * 3600_000).toISOString(), id]);
it('gooit alleen afgehandelde opdrachten weg', async () => {
const oud = await createCommand({ kind: 'navigate', payload: doel });
await claimNextCommand('tab-1');
await resolveCommand(oud, 'tab-1', 'done');
const open = await createCommand({ kind: 'navigate', payload: doel });
verouder(oud);
assert.equal(pruneCommands(), 1);
assert.equal(getCommand(oud), null);
assert.ok(getCommand(open), 'wat nog openstaat blijft, hoe oud ook');
});
it('ruimt op zodra er een nieuwe opdracht wordt klaargezet', async () => {
const oud = await createCommand({ kind: 'navigate', payload: doel });
await claimNextCommand('tab-1');
await resolveCommand(oud, 'tab-1', 'done');
verouder(oud);
const nieuw = await createCommand({ kind: 'navigate', payload: doel });
assert.equal(getCommand(oud), null, 'de oude is weg zonder dat iemand erom vroeg');
assert.ok(getCommand(nieuw), 'en de nieuwe staat er gewoon');
});
it('laat een recent afgehandelde opdracht staan', async () => {
const id = await createCommand({ kind: 'navigate', payload: doel });
await claimNextCommand('tab-1');
await resolveCommand(id, 'tab-1', 'done');
assert.equal(pruneCommands(), 0);
assert.ok(getCommand(id));
});
});
+165
View File
@@ -0,0 +1,165 @@
// Generated, do not edit. sha256:dddfdfd366ef82253787978dcf8368fdcb9f0cf259774eee0d63e666ef6c597c
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG, ENUMS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { counts, tableCounts } = await import('../../server/data/counts.js');
const { createScanLog, latestScanLog, listScanLogs } = await import('../../server/data/scan-logs.js');
let ctx;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
const btc = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Kluis','kluis','wallet',1,1,1,NULL)`);
ctx = { btc, account: harness.exports.lastId() };
});
function gebeurtenis() {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2024-01-01T00:00:00', 100000, 50000, ?, 'manual', '2024-01-01T00:00:00')`,
[ctx.btc, ctx.account]);
}
describe('counts', () => {
it('telt de vier hoofd-entiteiten', () => {
gebeurtenis();
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?, '2024-12-31', ?, 1, 1, '2024-12-31T23:00:00', 'manual')`, [ctx.btc, ctx.account]);
assert.deepEqual(counts(), { assets: 1, accounts: 1, events: 1, valuations: 1 });
});
it('geeft nullen op een lege database in plaats van niets', () => {
harness.exports.run(`DELETE FROM Asset`);
harness.exports.run(`DELETE FROM Account`);
assert.deepEqual(counts(), { assets: 0, accounts: 0, events: 0, valuations: 0 });
});
});
describe('tableCounts', () => {
it('geeft een rij per tabel uit de whitelist, in dezelfde volgorde', () => {
const rijen = tableCounts();
assert.deepEqual(rijen.map(r => r.name), [...CONFIG.DB_TABLES],
'de widget toont de lijst in configuratie-volgorde, dus die mag niet gesorteerd raken');
});
it('draait elke naam uit de whitelist echt af zonder te werpen', () => {
const rijen = tableCounts();
assert.ok(rijen.length >= 10, `verwacht een flinke whitelist, kreeg ${rijen.length}`);
for (const r of rijen) {
assert.equal(typeof r.n, 'number', `${r.name} gaf geen getal terug`);
assert.ok(r.n >= 0);
}
});
it('telt echt en geeft niet overal nul', () => {
gebeurtenis();
const perTabel = Object.fromEntries(tableCounts().map(r => [r.name, r.n]));
assert.equal(perTabel.Event, 1);
assert.equal(perTabel.Asset, 1);
assert.equal(perTabel.Valuation, 0);
});
});
describe('createScanLog', () => {
const scan = (extra = {}) => ({ accountId: ctx.account, status: 'success', ...extra });
const rij = (id) => harness.exports.one(`SELECT * FROM ScanLog WHERE id = ?`, [id]);
it('bewaart de velden en geeft het id terug', async () => {
const id = await createScanLog(scan({
scannedAt: '2024-05-01T12:00:00', addressesFound: 12, balanceFound: 750_000, proposalsCreated: 2,
}));
const s = rij(id);
assert.equal(s.accountId, ctx.account);
assert.equal(s.scannedAt, '2024-05-01T12:00:00');
assert.equal(s.status, 'success');
assert.equal(s.addressesFound, 12);
assert.equal(s.balanceFound, 750_000);
assert.equal(s.proposalsCreated, 2);
});
it('zet het tijdstip zelf als de aanroeper er geen geeft', async () => {
const id = await createScanLog(scan());
assert.ok(rij(id).scannedAt, 'een scan zonder tijdstip is niet te ordenen');
});
it('bewaart rawData als JSON-tekst', async () => {
const id = await createScanLog(scan({ rawData: { adressen: ['bc1q…'], saldo: 750_000 } }));
const ruw = rij(id).rawData;
assert.equal(typeof ruw, 'string');
assert.deepEqual(JSON.parse(ruw), { adressen: ['bc1q…'], saldo: 750_000 });
});
it('laat rawData leeg als er niets is', async () => {
const id = await createScanLog(scan());
assert.equal(rij(id).rawData, null, 'geen "null" als tekst, maar echt leeg');
});
it('accepteert elke status uit de registry en weigert de rest', async () => {
for (const status of ENUMS.SCAN_STATUS) {
const id = await createScanLog(scan({ status }));
assert.equal(rij(id).status, status);
}
await assert.rejects(() => createScanLog(scan({ status: 'bijna-goed' })), /status/);
});
it('persisteert', async () => {
const voor = harness.calls.persist;
await createScanLog(scan());
assert.equal(harness.calls.persist, voor + 1);
});
});
describe('latestScanLog en listScanLogs', () => {
const scan = (extra = {}) => createScanLog({ accountId: ctx.account, status: 'success', ...extra });
it('geeft de laatste scan van dat account', async () => {
await scan({ scannedAt: '2024-01-01T00:00:00' });
const laatste = await scan({ scannedAt: '2024-06-01T00:00:00' });
assert.equal(latestScanLog(ctx.account).id, laatste);
});
it('kiest bij een gelijk tijdstip de hoogste id', async () => {
await scan({ scannedAt: '2024-06-01T00:00:00', addressesFound: 1 });
const tweede = await scan({ scannedAt: '2024-06-01T00:00:00', addressesFound: 2 });
assert.equal(latestScanLog(ctx.account).id, tweede);
assert.equal(latestScanLog(ctx.account).addressesFound, 2);
});
it('geeft null als er nog nooit gescand is', () => {
assert.equal(latestScanLog(ctx.account), null);
});
it('kijkt alleen naar het gevraagde account', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','wallet',1,1,1,NULL)`);
const ander = harness.exports.lastId();
await scan({ scannedAt: '2024-06-01T00:00:00' });
assert.equal(latestScanLog(ander), null);
assert.deepEqual(listScanLogs(ander), []);
});
it('geeft de volledige historie, nieuwste eerst', async () => {
await scan({ scannedAt: '2024-01-01T00:00:00' });
await scan({ scannedAt: '2024-06-01T00:00:00' });
await scan({ scannedAt: '2024-03-01T00:00:00' });
assert.deepEqual(listScanLogs(ctx.account).map(s => s.scannedAt),
['2024-06-01T00:00:00', '2024-03-01T00:00:00', '2024-01-01T00:00:00']);
});
it('is append-only: een tweede scan overschrijft de eerste niet', async () => {
await scan({ scannedAt: '2024-01-01T00:00:00', status: 'failed' });
await scan({ scannedAt: '2024-06-01T00:00:00', status: 'success' });
assert.equal(listScanLogs(ctx.account).length, 2);
assert.deepEqual(listScanLogs(ctx.account).map(s => s.status), ['success', 'failed']);
});
});
+244
View File
@@ -0,0 +1,244 @@
// Generated, do not edit. sha256:b4d746208e0e2104c1a5250636bc0551b849cdc5c3d7fff3b175f2d7cb20fa75
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
listProfiles, activeProfile, createProfile, renameProfile, activateProfile, deleteProfile,
listWidgets, saveWidgets,
} = await import('../../server/data/dashboard.js');
const { widgetParams } = await import('../../client/data/dashboard.js');
const { DASHBOARD } = await import('../../shared/config.js');
function systeemprofiel() {
harness.exports.run(
`INSERT INTO DashboardProfile (name, searchName, description, sortOrder, isActive, isSystem, createdAt, updatedAt)
VALUES ('Standaard','standaard',NULL,0,1,1,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`);
return harness.exports.lastId();
}
let btc;
beforeEach(async () => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
btc = harness.exports.lastId();
const id = systeemprofiel();
await saveWidgets(id, null);
});
const namen = () => listProfiles().map(p => p.name);
const actieveNaam = () => activeProfile().name;
const aantalActief = () => harness.exports.one(`SELECT COUNT(*) AS n FROM DashboardProfile WHERE isActive = 1`).n;
describe('exact één actief profiel', () => {
it('houdt het bij één als je wisselt', async () => {
const id = await createProfile({ name: 'Fiscaal' });
assert.equal(aantalActief(), 1, 'een nieuw profiel komt inactief binnen');
await activateProfile(id);
assert.equal(aantalActief(), 1);
assert.equal(actieveNaam(), 'Fiscaal');
});
it('kan heen en terug wisselen', async () => {
const id = await createProfile({ name: 'Fiscaal' });
const systeem = listProfiles().find(p => p.isSystem).id;
await activateProfile(id);
await activateProfile(systeem);
assert.equal(aantalActief(), 1);
assert.equal(actieveNaam(), 'Standaard');
});
it('wijst een opvolger aan als je het actieve profiel verwijdert', async () => {
const id = await createProfile({ name: 'Fiscaal' });
await activateProfile(id);
await deleteProfile(id);
assert.equal(aantalActief(), 1);
assert.equal(actieveNaam(), 'Standaard');
});
it('zet het systeemprofiel terug op de standaardindeling in plaats van het te verwijderen', async () => {
const systeem = listProfiles().find(p => p.isSystem).id;
await saveWidgets(systeem, [{ instanceKey: 'clocks', type: 'clocks', gridX: 3, gridY: 2, spanWidth: 6, spanHeight: 4, params: { cities: ['Tokyo'] } }]);
await deleteProfile(systeem);
assert.deepEqual(namen(), ['Standaard'], 'de rij blijft bestaan');
const widgets = listWidgets(systeem);
assert.deepEqual(widgets.map(w => w.instanceKey).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.instId).sort());
assert.ok(widgets.every(w => w.spanHeight == null && w.settings == null), 'vers: niet gemeten, geen instellingen');
assert.deepEqual(Object.fromEntries(widgets.map(w => [w.instanceKey, w.gridX])),
Object.fromEntries(DASHBOARD.DEFAULT_LAYOUT.map(w => [w.instId, w.x || 0])));
assert.equal(actieveNaam(), 'Standaard', 'het blijft het actieve profiel');
});
it('laat bij het terugzetten van het systeemprofiel een ander actief profiel en zijn indeling staan', async () => {
const id = await createProfile({ name: 'Fiscaal' });
await saveWidgets(id, [{ instanceKey: 'signal', type: 'signal', gridX: 0, gridY: 0, spanWidth: 4, spanHeight: 3, params: null }]);
await activateProfile(id);
await deleteProfile(listProfiles().find(p => p.isSystem).id);
assert.equal(actieveNaam(), 'Fiscaal');
assert.equal(aantalActief(), 1);
assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['signal'], 'alleen het systeemprofiel begint opnieuw');
});
it('weigert een onbekend profiel in alle richtingen', async () => {
await assert.rejects(() => activateProfile(9999), (e) => e.key === 'dashboard.unknownProfile');
await assert.rejects(() => deleteProfile(9999), (e) => e.key === 'dashboard.unknownProfile');
await assert.rejects(() => renameProfile(9999, { name: 'X' }), (e) => e.key === 'dashboard.unknownProfile');
await assert.rejects(() => saveWidgets(9999, []), (e) => e.key === 'dashboard.unknownProfile');
});
it('klaagt hoorbaar als er tóch geen actief profiel is', () => {
harness.exports.run(`UPDATE DashboardProfile SET isActive = 0`);
assert.throws(() => activeProfile(), /No active dashboard profile/);
});
});
describe('profielen beheren', () => {
it('geeft een nieuw profiel de standaardindeling en een eigen sortOrder', async () => {
const id = await createProfile({ name: 'Fiscaal', description: 'Voor de aangifte' });
const p = listProfiles().find(x => x.id === id);
assert.equal(p.description, 'Voor de aangifte');
assert.equal(p.isSystem, 0);
assert.ok(p.sortOrder > 0, 'komt achter het systeemprofiel');
assert.equal(listWidgets(id).length, DASHBOARD.DEFAULT_LAYOUT.length);
});
it('leidt searchName af en weigert een dubbele naam', async () => {
const id = await createProfile({ name: 'Fiscaal' });
assert.equal(listProfiles().find(x => x.id === id).searchName, 'fiscaal');
await assert.rejects(() => createProfile({ name: 'fiscaal' }));
});
it('weigert een lege naam bij aanmaken en hernoemen', async () => {
await assert.rejects(() => createProfile({ name: ' ' }), (e) => e.key === 'dashboard.nameRequired');
const systeem = listProfiles().find(p => p.isSystem).id;
await assert.rejects(() => renameProfile(systeem, { name: '' }), (e) => e.key === 'dashboard.nameRequired');
});
it('hernoemt met searchName mee', async () => {
const id = await createProfile({ name: 'Fiscaal' });
await renameProfile(id, { name: 'Belasting' });
const p = listProfiles().find(x => x.id === id);
assert.equal(p.name, 'Belasting');
assert.equal(p.searchName, 'belasting');
});
it('neemt de widgets mee als een profiel verdwijnt', async () => {
const id = await createProfile({ name: 'Fiscaal' });
assert.ok(listWidgets(id).length > 0);
await deleteProfile(id);
assert.equal(harness.exports.one(
`SELECT COUNT(*) AS n FROM DashboardWidget WHERE profileId = ?`, [id]).n, 0);
assert.deepEqual(namen(), ['Standaard']);
});
});
describe('widgets van een profiel', () => {
const indeling = (extra = {}) => [{
instanceKey: 'signal', type: 'signal', gridX: 0, gridY: 0, spanWidth: 4, spanHeight: 3, params: null, ...extra,
}];
it('vervangt de hele set', async () => {
const id = activeProfile().id;
await saveWidgets(id, indeling());
assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['signal'],
'de standaardindeling is vervangen, niet aangevuld');
});
it('slaat een leeg raster op als leeg en niet als de standaard', async () => {
const id = activeProfile().id;
await saveWidgets(id, []);
assert.deepEqual(listWidgets(id), []);
});
it('herstelt de standaardindeling met null', async () => {
const id = activeProfile().id;
await saveWidgets(id, []);
await saveWidgets(id, null);
assert.deepEqual(listWidgets(id).map(w => w.type).sort(), DASHBOARD.DEFAULT_LAYOUT.map(w => w.type).sort());
});
it('bewaart een ongemeten hoogte als NULL', async () => {
const id = activeProfile().id;
await saveWidgets(id, indeling({ spanHeight: null }));
assert.equal(listWidgets(id)[0].spanHeight, null);
});
it('weigert een onbekend widgettype zónder iets weg te gooien', async () => {
const id = activeProfile().id;
const voor = listWidgets(id).length;
await assert.rejects(() => saveWidgets(id, indeling({ type: 'kwartaalcijfers' })), /Unknown widget type/);
assert.equal(listWidgets(id).length, voor, 'de bestaande indeling staat er nog');
});
it('splitst assetId naar zijn eigen kolom en de rest naar settings', async () => {
const id = activeProfile().id;
await saveWidgets(id, indeling({
instanceKey: 'spot-1', type: 'spot', params: { assetId: btc, cities: ['amsterdam'] },
}));
const rij = listWidgets(id)[0];
assert.equal(rij.assetId, btc, 'een verwijzing hoort een kolom te zijn, want die moet bewaakt worden');
assert.equal(rij.settings, JSON.stringify({ cities: ['amsterdam'] }),
'assetId staat NIET ook in settings: twee plekken die hetzelfde beweren gaan afwijken');
});
it('geeft ze als één object terug', async () => {
const id = activeProfile().id;
await saveWidgets(id, indeling({
instanceKey: 'clocks', type: 'clocks', params: { cities: ['tokyo'], showExchanges: true },
}));
assert.deepEqual(widgetParams(listWidgets(id)[0]), { cities: ['tokyo'], showExchanges: true });
});
it('geeft null als er niets te melden is', async () => {
const id = activeProfile().id;
await saveWidgets(id, indeling());
assert.equal(listWidgets(id)[0].settings, null, 'geen leeg {} in de kolom');
assert.equal(widgetParams(listWidgets(id)[0]), null);
});
it('overleeft onleesbare JSON in settings', async () => {
const id = activeProfile().id;
await saveWidgets(id, indeling({ instanceKey: 'spot-1', type: 'spot', params: { assetId: btc } }));
harness.exports.run(`UPDATE DashboardWidget SET settings = '{kapot' WHERE profileId = ?`, [id]);
assert.deepEqual(widgetParams(listWidgets(id)[0]), { assetId: btc });
});
it('neemt de koers-widget mee als zijn asset verdwijnt', async () => {
const id = activeProfile().id;
await saveWidgets(id, [
...indeling(),
{ instanceKey: 'spot-1', type: 'spot', gridX: 4, gridY: 0, spanWidth: 2, spanHeight: 2, params: { assetId: btc } },
]);
harness.exports.run(`DELETE FROM Asset WHERE id = ?`, [btc]);
assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['signal']);
});
it('houdt de instanties per profiel gescheiden', async () => {
const eerste = activeProfile().id;
const tweede = await createProfile({ name: 'Fiscaal' });
await saveWidgets(tweede, indeling());
assert.equal(listWidgets(eerste).length, DASHBOARD.DEFAULT_LAYOUT.length);
assert.deepEqual(listWidgets(tweede).map(w => w.instanceKey), ['signal']);
});
it('geeft de instanties in leesvolgorde terug', async () => {
const id = activeProfile().id;
await saveWidgets(id, [
{ instanceKey: 'c', type: 'signal', gridX: 0, gridY: 5, spanWidth: 4, spanHeight: 1, params: null },
{ instanceKey: 'b', type: 'metrics', gridX: 6, gridY: 0, spanWidth: 3, spanHeight: 1, params: null },
{ instanceKey: 'a', type: 'netshare', gridX: 0, gridY: 0, spanWidth: 3, spanHeight: 1, params: null },
]);
assert.deepEqual(listWidgets(id).map(w => w.instanceKey), ['a', 'b', 'c']);
});
it('weigert twee instanties met dezelfde sleutel in één profiel', async () => {
const id = activeProfile().id;
await assert.rejects(() => saveWidgets(id, [...indeling(), ...indeling()]));
});
});
+148
View File
@@ -0,0 +1,148 @@
// Generated, do not edit. sha256:605d840e94e42af14d150c5b1095887b6d46b752b6e0dc0407b3f4a209ee7b66
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { toSearchName } from '../../shared/search-name.js';
import { isOwnWealth } from '../../shared/wealth-scope.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { assetPositions } = await import('../../server/data/events.js');
const { positionsByAsset } = await import('../../server/data/positions.js');
const { latestAnchors } = await import('../../server/data/valuations.js');
const { metalHoldings } = await import('../../server/data/metals.js');
const { run, lastId } = harness.exports;
const PEIL = '2026-12-31T00:00:00.000Z';
const rijVan = (rijen, id) => rijen.find(r => r.id === id);
const eigen = (assetId) => rijVan(positionsByAsset(PEIL, { ownOnly: true }), assetId);
const alles = (assetId) => rijVan(positionsByAsset(PEIL), assetId);
let btc, xau, munt, kluis, spaarpot, pensioen;
beforeEach(() => {
harness.recreate();
btc = asset('BTC', 'Bitcoin', 'crypto', 8);
xau = asset('XAU', 'Goud', 'metal', 4);
run(`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?, 'Krugerrand', 'krugerrand', 'coin', 311035)`, [xau]);
munt = lastId();
kluis = account('Kluis', null);
spaarpot = account('Spaarpot kind', 'managed');
pensioen = account('Pensioen', 'none');
});
function asset(symbol, naam, kind, decimals) {
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?,?,?,?,?,NULL,?,'unit')`, [symbol, naam, toSearchName(naam), kind, symbol, decimals]);
return lastId();
}
function account(naam, wealthScope) {
run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt, wealthScope)
VALUES (?,?,'bank',1,0,1,NULL,?)`, [naam, toSearchName(naam), wealthScope]);
return lastId();
}
function event({ assetId = null, type = 'buy', to = null, from = null, amount = 1000, unitFee = null,
priceOrigin = null, productId = null, productCount = null, at = '2026-03-01T12:00:00.000Z' } = {}) {
const prijs = type === 'buy' || type === 'sell' ? 50000 : null;
run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee, productId, productCount,
fromAccountId, toAccountId, source, priceOrigin, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,'manual',?,?)`,
[assetId ?? btc, type, at, amount, prijs, type === 'buy' ? 10 : null, unitFee, productId, productCount,
from, to, priceOrigin, at]);
return lastId();
}
function balans({ assetId = null, acc, balance, at = '2026-06-30T00:00:00.000Z' }) {
run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`, [assetId ?? btc, at.slice(0, 10), acc, balance, at]);
return lastId();
}
describe('isOwnWealth', () => {
it('is waar zonder account en zonder keuze, onwaar bij beheerd en bij telt niet mee', () => {
assert.equal(isOwnWealth(null), true, 'wat aan geen account hangt is van niemand anders');
assert.equal(isOwnWealth({ wealthScope: null }), true);
assert.equal(isOwnWealth({ wealthScope: 'managed' }), false);
assert.equal(isOwnWealth({ wealthScope: 'none' }), false);
});
});
describe('ownOnly zonder afwijking', () => {
it('geeft dezelfde rijen als zonder de optie, ook met een gift, een fee en een zelf-transfer', () => {
event({ to: kluis, amount: 100_000 });
event({ to: null, amount: 20_000, at: '2026-03-02T00:00:00.000Z' });
event({ type: 'transfer', from: kluis, to: null, amount: 5_000, unitFee: 300, at: '2026-04-01T00:00:00.000Z' });
event({ type: 'transfer', from: null, to: kluis, amount: 7_000, priceOrigin: 'none', at: '2026-04-02T00:00:00.000Z' });
event({ type: 'transfer', from: kluis, to: kluis, amount: 1_000, unitFee: 50, at: '2026-04-03T00:00:00.000Z' });
event({ type: 'sell', from: kluis, amount: 10_000, at: '2026-05-01T00:00:00.000Z' });
event({ assetId: xau, to: kluis, amount: 622_070, productId: munt, productCount: 2 });
balans({ acc: kluis, balance: 90_000 });
assert.deepEqual(assetPositions(PEIL, { ownOnly: true }), assetPositions(PEIL));
assert.deepEqual(positionsByAsset(PEIL, { ownOnly: true }), positionsByAsset(PEIL));
assert.deepEqual(latestAnchors(PEIL, { ownOnly: true }), latestAnchors(PEIL));
assert.deepEqual(metalHoldings({ ownOnly: true }), metalHoldings());
});
});
describe('ownOnly over de grens van het eigen vermogen', () => {
it('telt een aankoop op een beheerd account niet, ook niet in de aankoopcijfers', () => {
event({ to: kluis, amount: 100_000 });
event({ to: spaarpot, amount: 40_000 });
assert.equal(alles(btc).net, 140_000);
assert.equal(alles(btc).buy_events, 2);
const r = eigen(btc);
assert.equal(r.net, 100_000);
assert.equal(r.buy_events, 1);
assert.equal(r.buy_amount, 100_000);
assert.equal(r.buy_fee_total, 10, 'alleen de fee van de eigen aankoop');
});
it('telt een overboeking naar een spaarpot als onttrekking, met de fee', () => {
event({ to: kluis, amount: 100_000 });
event({ type: 'transfer', from: kluis, to: spaarpot, amount: 30_000, unitFee: 200, at: '2026-04-01T00:00:00.000Z' });
assert.equal(alles(btc).net, 99_800, 'in het geheel valt alleen de fee weg');
assert.equal(eigen(btc).net, 69_800, 'het eigen vermogen ziet het geld vertrekken');
assert.equal(eigen(btc).gift_amount, 0, 'een overboeking met een prijsherkomst is geen gift');
});
it('telt een overboeking terug als storting', () => {
event({ to: spaarpot, amount: 50_000 });
event({ type: 'transfer', from: spaarpot, to: kluis, amount: 20_000, at: '2026-04-01T00:00:00.000Z' });
assert.equal(eigen(btc).net, 20_000);
assert.equal(alles(btc).net, 50_000);
});
it('negeert een balans op een beheerd account, en houdt die op een eigen account', () => {
event({ to: kluis, amount: 100_000 });
event({ to: spaarpot, amount: 40_000 });
balans({ acc: spaarpot, balance: 45_000 });
balans({ acc: kluis, balance: 101_000 });
assert.equal(alles(btc).net, 146_000);
assert.equal(eigen(btc).net, 101_000, 'de spaarpot verschuift het eigen vermogen niet');
assert.deepEqual(latestAnchors(PEIL, { ownOnly: true }).map(a => a.accountId), [kluis]);
});
it('houdt een boeking zonder account bij het eigen vermogen', () => {
event({ to: null, amount: 25_000 });
assert.equal(eigen(btc).net, 25_000);
});
it('behandelt telt niet mee net als beheerd', () => {
event({ to: kluis, amount: 100_000 });
event({ to: pensioen, amount: 60_000 });
event({ type: 'transfer', from: kluis, to: pensioen, amount: 10_000, at: '2026-04-01T00:00:00.000Z' });
assert.equal(eigen(btc).net, 90_000);
assert.equal(alles(btc).net, 160_000);
});
it('telt munten die naar een spaarpot gaan niet meer bij het eigen vermogen', () => {
event({ assetId: xau, to: kluis, amount: 933_105, productId: munt, productCount: 3 });
event({ assetId: xau, to: spaarpot, amount: 311_035, productId: munt, productCount: 1 });
event({ assetId: xau, type: 'transfer', from: kluis, to: spaarpot, amount: 311_035, productId: munt,
productCount: 1, at: '2026-04-01T00:00:00.000Z' });
assert.equal(metalHoldings()[0].net_count, 4, 'het geheel: vier gekocht, een verplaatsing telt niet');
const eigenMunt = metalHoldings({ ownOnly: true })[0];
assert.equal(eigenMunt.net_count, 2, 'drie gekocht, één naar de spaarpot');
assert.equal(eigenMunt.buy_events, 1, 'de aankoop op de spaarpot telt niet mee');
});
});
+705
View File
@@ -0,0 +1,705 @@
// Generated, do not edit. sha256:3f3763318dbbed19b2856eb5ad9116f8f3871486bcca510f894e39694929a1e0
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
createEvent, eventsCountByAccount, eventsNetByAccount,
eventsWithoutImportRef, findEventByRef, firstTaxEventDate, listAssetTrades, listEvents, listFlowLegs,
listTradeEvents, listTrades, listTransfers, updateEvent
} = await import('../../server/data/events.js');
const { listInterest, listInterestForAccount, scheduleLabel } = await import('../../server/data/interest-schedules.js');
const {
anchorBefore, listOpenValuationDiffs, listValuations, valuationDiff, valuationDiffs, valuationGap,
valuationReconcilable
} = await import('../../server/data/valuations.js');
let btc, eth, accA, accB;
beforeEach(() => {
harness.recreate();
btc = asset('BTC');
eth = asset('ETH');
accA = account('A');
accB = account('B');
});
function asset(symbol) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,'crypto',?,NULL,8,'unit')`, [symbol, symbol, toSearchName(symbol), symbol]);
return harness.exports.lastId();
}
function account(naam) {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,'wallet',1,0,1,NULL)`, [naam, toSearchName(naam)]);
return harness.exports.lastId();
}
function event({ type = 'buy', assetId = null, from = null, to = null, amount = 1000,
at = '2024-01-01T00:00:00', fiatPrice = null, fiatFee = null, unitFee = null,
ref = null, source = 'manual' } = {}) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, unitFee,
fromAccountId, toAccountId, source, importReference, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
[assetId ?? btc, type, at, amount,
fiatPrice ?? (type === 'buy' || type === 'sell' ? 30000 : null), fiatFee, unitFee,
from, to, source, ref, at]);
return harness.exports.lastId();
}
function waardering({ acc, onDate, balance, assetId = null, rateAt = null }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?, 'manual')`,
[assetId ?? btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]);
return harness.exports.lastId();
}
describe('eventsNetByAccount', () => {
const net = (acc = accA, assetId = btc, until = '2030-01-01T00:00:00') =>
eventsNetByAccount(acc, assetId, until);
it('is 0 zonder events', () => {
assert.equal(net(), 0);
});
it('telt een aankoop op het ontvangende account op', () => {
event({ type: 'buy', to: accA, amount: 1000 });
assert.equal(net(), 1000);
});
it('trekt een verkoop van het afgevende account af', () => {
event({ type: 'sell', from: accA, amount: 400 });
assert.equal(net(), -400);
});
it('trekt bij een verkoop ook de fee af', () => {
event({ type: 'sell', from: accA, amount: 400, unitFee: 3 });
assert.equal(net(), -403);
});
it('telt rente op het ontvangende account op', () => {
event({ type: 'interest', to: accA, amount: 25 });
assert.equal(net(), 25);
});
it('telt een inkomende transfer vól mee, zonder fee-aftrek', () => {
event({ type: 'transfer', from: accB, to: accA, amount: 1000, unitFee: 20 });
assert.equal(net(), 1000);
});
it('trekt bij een uitgaande transfer het bedrag én de fee af', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 1000, unitFee: 20 });
assert.equal(net(), -1020);
});
it('rekent een transfer naar hetzelfde account als alleen de fee', () => {
event({ type: 'transfer', from: accA, to: accA, amount: 1000, unitFee: 20 });
assert.equal(net(), -20);
});
it('behandelt een ontbrekende fee als 0', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 1000, unitFee: null });
assert.equal(net(), -1000);
});
it('negeert events van een ander asset', () => {
event({ type: 'buy', to: accA, amount: 1000, assetId: eth });
assert.equal(net(accA, btc), 0);
assert.equal(net(accA, eth), 1000);
});
it('negeert events van een ander account', () => {
event({ type: 'buy', to: accB, amount: 1000 });
assert.equal(net(accA), 0);
});
it('neemt het peilmoment zelf mee (t/m, niet tot)', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-03-01T12:00:00' });
assert.equal(net(accA, btc, '2024-03-01T12:00:00'), 1000, 'gelijk aan het peilmoment telt mee');
assert.equal(net(accA, btc, '2024-03-01T11:59:59'), 0, 'daarvóór nog niet');
});
it('telt zonder peilmoment alles mee', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2030-06-01T00:00:00' });
assert.equal(eventsNetByAccount(accA, btc, null), 1000);
});
it('somt een reeks correct op', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' });
event({ type: 'transfer', from: accA, to: accB, amount: 300, unitFee: 10, at: '2024-03-01T00:00:00' });
event({ type: 'sell', from: accA, amount: 200, at: '2024-04-01T00:00:00' });
assert.equal(net(), 1000 + 50 - 310 - 200);
});
});
describe('listTradeEvents', () => {
beforeEach(() => {
event({ type: 'buy', to: accA, at: '2023-12-31T23:59:59', amount: 1 });
event({ type: 'buy', to: accA, at: '2024-01-01T00:00:00', amount: 2 });
event({ type: 'sell', from: accA, at: '2024-06-01T00:00:00', amount: 3 });
event({ type: 'buy', to: accA, at: '2024-12-31T23:59:59', amount: 4 });
event({ type: 'buy', to: accA, at: '2025-01-01T00:00:00', amount: 5 });
event({ type: 'transfer', from: accA, to: accB, at: '2024-05-01T00:00:00', amount: 99 });
event({ type: 'interest', to: accA, at: '2024-05-02T00:00:00', amount: 98 });
});
const bedragen = (van, tot) => listTradeEvents(van, tot).map(e => e.amount).sort((a, b) => a - b);
it('neemt alleen buy en sell mee', () => {
assert.deepEqual(bedragen(null, '2030-01-01T00:00:00'), [1, 2, 3, 4, 5]);
});
it('sluit de ondergrens uit en de bovengrens in', () => {
assert.deepEqual(bedragen('2024-01-01T00:00:00', '2024-12-31T23:59:59'), [3, 4],
'de aankoop exact op de ondergrens hoort bij het vorige jaar');
});
it('betekent van=null: vanaf het begin', () => {
assert.deepEqual(bedragen(null, '2024-01-01T00:00:00'), [1, 2]);
});
it('geeft niets terug voor een leeg venster', () => {
assert.deepEqual(bedragen('2024-07-01T00:00:00', '2024-07-31T00:00:00'), []);
});
it('neemt een verplaatsing tussen twee eigen accounts alleen mee als hij een euro-fee draagt', () => {
event({ type: 'transfer', from: accA, to: accB, at: '2024-08-01T00:00:00', amount: 97, fiatFee: 2 });
event({ type: 'transfer', from: accA, to: accB, at: '2024-08-02T00:00:00', amount: 96, fiatFee: 0 });
assert.deepEqual(bedragen('2024-07-31T00:00:00', '2024-08-31T00:00:00'), [97]);
});
it('neemt elke eenzijdige verplaatsing mee, naar buiten en van buiten', () => {
event({ type: 'transfer', from: accA, to: null, at: '2024-08-03T00:00:00', amount: 95 });
event({ type: 'transfer', from: null, to: accB, at: '2024-08-04T00:00:00', amount: 94 });
assert.deepEqual(bedragen('2024-07-31T00:00:00', '2024-08-31T00:00:00'), [94, 95]);
});
it('levert de velden die de aanwasberekening nodig heeft', () => {
const e = listTradeEvents('2024-05-31T00:00:00', '2024-06-02T00:00:00')[0];
assert.deepEqual(Object.keys(e).sort(),
['amount', 'assetId', 'decimals', 'fiatFee', 'fiatPrice', 'fromAccountId', 'occurredAt',
'priceOrigin', 'toAccountId', 'type']);
assert.equal(e.decimals, 8, 'de decimals van het asset komen mee voor de omrekening');
});
});
describe('listFlowLegs', () => {
const benen = (van, tot) => listFlowLegs(van, tot)
.map(l => `${l.accountId}:${l.type}:${l.direction}:${l.units}`).sort();
it('geeft een aankoop als één been op de ontvangende rekening', () => {
event({ type: 'buy', to: accA, amount: 100, fiatPrice: 20000, at: '2024-02-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:buy:in:100`]);
});
it('geeft een verkoop als één been op de leverende rekening', () => {
event({ type: 'sell', from: accA, amount: 100, fiatPrice: 20000, at: '2024-02-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:sell:out:100`]);
});
it('geeft een verplaatsing als TWEE benen, één per rekening', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 300, at: '2024-03-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'),
[`${accA}:transfer:out:300`, `${accB}:transfer:in:300`].sort());
});
it('laat de unitFee van een verplaatsing BUITEN de stroom', () => {
event({ type: 'transfer', from: accA, to: accB, amount: 300, unitFee: 10, at: '2024-03-01T00:00:00' });
const uit = listFlowLegs(null, '2025-01-01T00:00:00').find(l => l.direction === 'out');
assert.equal(uit.units, 300, 'de fee zit niet in de stroom');
});
it('laat rente helemaal weg', () => {
event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), []);
});
it('geeft bij een verplaatsing naar zichzelf twee benen die elkaar opheffen', () => {
event({ type: 'transfer', from: accA, to: accA, amount: 300, at: '2024-03-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'),
[`${accA}:transfer:in:300`, `${accA}:transfer:out:300`].sort());
});
it('houdt één been over bij een verplaatsing van of naar buiten', () => {
event({ type: 'transfer', from: accA, to: null, amount: 300, at: '2024-03-01T00:00:00' });
assert.deepEqual(benen(null, '2025-01-01T00:00:00'), [`${accA}:transfer:out:300`]);
});
it('scheidt de assets', () => {
event({ type: 'buy', to: accA, assetId: eth, amount: 7, fiatPrice: 3000, at: '2024-02-01T00:00:00' });
const l = listFlowLegs(null, '2025-01-01T00:00:00')[0];
assert.equal(l.assetId, eth);
});
it('sluit de ondergrens uit en de bovengrens in', () => {
event({ type: 'buy', to: accA, amount: 1, fiatPrice: 1, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: accA, amount: 2, fiatPrice: 1, at: '2024-12-31T23:59:59' });
assert.deepEqual(benen('2024-01-01T00:00:00', '2024-12-31T23:59:59'), [`${accA}:buy:in:2`]);
});
it('levert de velden die de waardering nodig heeft', () => {
event({ type: 'buy', to: accA, amount: 100, fiatPrice: 20000, fiatFee: 5, at: '2024-02-01T00:00:00' });
const l = listFlowLegs(null, '2025-01-01T00:00:00')[0];
assert.deepEqual(Object.keys(l).sort(),
['accountId', 'assetId', 'decimals', 'direction', 'fiatFee', 'fiatPrice', 'occurredAt', 'priceOrigin',
'type', 'units']);
assert.equal(l.decimals, 8, 'de decimals komen mee voor de omrekening');
assert.equal(l.occurredAt, '2024-02-01T00:00:00', 'de dag komt mee, want een verplaatsing wordt op de koers van die dag gewaardeerd');
});
});
describe('firstTaxEventDate', () => {
it('geeft null zonder events', () => {
assert.equal(firstTaxEventDate(), null);
});
it('geeft de vroegste gebeurtenis, niet de eerst ingevoerde', () => {
event({ at: '2024-06-01T00:00:00' });
event({ at: '2022-03-15T00:00:00' });
event({ at: '2023-01-01T00:00:00' });
assert.equal(firstTaxEventDate(), '2022-03-15T00:00:00');
});
});
describe('event-lijsten', () => {
it('sorteert listEvents nieuwste eerst', () => {
const oud = event({ at: '2024-01-01T00:00:00' });
const nieuw = event({ at: '2024-06-01T00:00:00' });
assert.deepEqual(listEvents().map(e => e.id), [nieuw, oud]);
});
it('splitst trades en transfers', () => {
const koop = event({ type: 'buy', to: accA });
const verkoop = event({ type: 'sell', from: accA });
const verplaatsing = event({ type: 'transfer', from: accA, to: accB });
event({ type: 'interest', to: accA });
assert.deepEqual(listTrades().map(e => e.id).sort((a, b) => a - b), [koop, verkoop]);
assert.deepEqual(listTransfers().map(e => e.id), [verplaatsing]);
});
it('geeft alleen de aan- en verkopen van het gevraagde asset', () => {
const koop = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
const verkoop = event({ type: 'sell', from: accA, at: '2024-05-01T00:00:00' });
event({ type: 'transfer', from: accA, to: accB, at: '2024-04-01T00:00:00' });
event({ type: 'interest', to: accA, at: '2024-04-15T00:00:00' });
event({ type: 'buy', assetId: eth, to: accA, at: '2024-02-01T00:00:00' });
assert.deepEqual(listAssetTrades(btc).map(e => e.id), [koop, verkoop]);
});
it('sorteert oplopend, want hij staat naast een grafiek die van links naar rechts loopt', () => {
const laat = event({ type: 'buy', to: accA, at: '2024-06-01T00:00:00' });
const vroeg = event({ type: 'buy', to: accA, at: '2024-01-01T00:00:00' });
assert.deepEqual(listAssetTrades(btc).map(e => e.id), [vroeg, laat]);
});
it('geeft niets terug voor een asset zonder handel', () => {
event({ type: 'buy', to: accA });
assert.deepEqual(listAssetTrades(eth), []);
});
it('draagt de rekening als id én als naam, want de dialoog groepeert op het id', () => {
event({ type: 'buy', to: accA });
event({ type: 'sell', from: accB });
const [koop, verkoop] = listAssetTrades(btc);
assert.equal(koop.toAccountId, accA);
assert.equal(koop.fromAccountId, null, 'een aankoop komt ergens BINNEN, dus alleen de to-kant');
assert.equal(koop.to_name, 'A');
assert.equal(verkoop.fromAccountId, accB);
assert.equal(verkoop.toAccountId, null);
assert.equal(verkoop.from_name, 'B');
});
function label(naam) {
harness.exports.run(`INSERT INTO Label (name, searchName, createdAt) VALUES (?,?,?)`,
[naam, toSearchName(naam), '2024-01-01T00:00:00']);
return harness.exports.lastId();
}
function beweging({ labelId = null, txid = 'tx-1' } = {}) {
harness.exports.run(
`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, labelId, blockHeight, blockTime, createdAt)
VALUES (?,'onchain',?,'in',?,?,?,?,?)`,
[accA, txid, 100_000, labelId, 840_000, '2024-03-01T00:00:00', '2024-03-02T00:00:00']);
return harness.exports.lastId();
}
const koppel = (txId, eventId) => harness.exports.run(
`INSERT INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
VALUES (?,?, 'manual', '2024-03-02T00:00:00')`, [txId, eventId]);
it('draagt het label van de gekoppelde beweging mee', () => {
const ev = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
koppel(beweging({ labelId: label('Van Jan') }), ev);
assert.equal(listAssetTrades(btc)[0].label_names, 'Van Jan');
});
it('laat het label leeg als er geen beweging of geen label is', () => {
const zonderKoppeling = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
const zonderLabel = event({ type: 'buy', to: accA, at: '2024-04-01T00:00:00' });
koppel(beweging({ txid: 'tx-2' }), zonderLabel);
const rijen = listAssetTrades(btc);
assert.equal(rijen.length, 2, 'beide aankopen staan er, met en zonder koppeling');
assert.equal(rijen[0].label_names, null, 'geen koppeling, geen label');
assert.equal(rijen[1].label_names, null, 'wel een beweging, maar die draagt geen label');
assert.equal(rijen[0].id, zonderKoppeling);
});
it('HOUDT ÉÉN RIJ bij twee gekoppelde bewegingen, met beide labels', () => {
const ev = event({ type: 'buy', to: accA, at: '2024-03-01T00:00:00' });
koppel(beweging({ txid: 'tx-1', labelId: label('Van Jan') }), ev);
koppel(beweging({ txid: 'tx-2', labelId: label('Van Piet') }), ev);
const rijen = listAssetTrades(btc);
assert.equal(rijen.length, 1, 'één aankoop blijft één rij, wat er ook aan hangt');
assert.deepEqual(rijen[0].label_names.split(',').sort(), ['Van Jan', 'Van Piet']);
});
it('verrijkt met asset- en accountnamen', () => {
event({ type: 'transfer', from: accA, to: accB });
const t = listTransfers()[0];
assert.equal(t.asset_symbol, 'BTC');
assert.equal(t.from_name, 'A');
assert.equal(t.to_name, 'B');
assert.equal(t.asset_decimals, 8);
});
it('geeft rente per account op basis van de bestemming', () => {
event({ type: 'interest', to: accA, amount: 10 });
event({ type: 'interest', to: accB, amount: 20 });
assert.deepEqual(listInterestForAccount(accA).map(e => e.amount), [10]);
});
it('vindt een event op import-referentie', () => {
const id = event({ ref: 'bitvavo-42' });
assert.equal(findEventByRef('bitvavo-42').id, id);
assert.equal(findEventByRef('bestaat-niet'), null);
assert.equal(findEventByRef(null), null, 'een lege referentie matcht nooit');
});
it('levert de pool zonder import-referentie voor de fuzzy match', () => {
const handmatig = event({ ref: null });
event({ ref: 'uit-import' });
assert.deepEqual(eventsWithoutImportRef().map(e => e.id), [handmatig]);
});
});
describe('listInterest: attributie aan een afspraak', () => {
function afspraak({ acc = accA, assetId = null, van, tot = null, rate = 0.05, type = 'fixed' }) {
harness.exports.run(
`INSERT INTO InterestSchedule
(accountId, rateKind, principalAssetId, payoutAssetId, principalAmount, annualRate,
startsOn, endsOn, creditCadence, compounding, createdAt)
VALUES (?,?,?,?,?,?,?,?,?,0,?)`,
[acc, type, type === 'fixed' ? (assetId ?? btc) : null, assetId ?? btc,
type === 'fixed' ? 100000 : null, type === 'fixed' ? rate : null,
van, tot, type === 'fixed' ? 'term_end' : null, '2024-01-01T00:00:00']);
return harness.exports.lastId();
}
it('koppelt een rente-event aan de afspraak van dat account en asset', () => {
const s = afspraak({ van: '2024-01-01', tot: '2024-12-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, s);
});
it('koppelt niet buiten het datumvenster', () => {
afspraak({ van: '2024-01-01', tot: '2024-05-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, null);
});
it('koppelt niet aan een afspraak van een ander account of asset', () => {
afspraak({ acc: accB, van: '2024-01-01', tot: '2024-12-31' });
afspraak({ acc: accA, assetId: eth, van: '2024-01-01', tot: '2024-12-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, null);
});
it('laat bij overlap de afspraak met de laatste startdatum winnen', () => {
afspraak({ van: '2024-01-01', tot: '2024-12-31' });
const nieuwer = afspraak({ van: '2024-03-01', tot: '2024-12-31' });
event({ type: 'interest', to: accA, at: '2024-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, nieuwer,
'de meest recente afspraak overschrijft de oudere');
});
it('koppelt aan een lopende afspraak zonder einddatum', () => {
const s = afspraak({ van: '2024-01-01', tot: null, type: 'variable' });
event({ type: 'interest', to: accA, at: '2029-06-01T00:00:00' });
assert.equal(listInterest()[0].scheduleId, s);
});
});
describe('scheduleLabel', () => {
const basis = { account_name: 'Spaarrekening', payout_symbol: 'BTC', rateKind: 'fixed',
annualRate: 0.05, startsOn: '2024-01-01', endsOn: '2024-12-31' };
it('zet accountnaam, asset, rente en periode achter elkaar', () => {
assert.equal(scheduleLabel(basis), 'Spaarrekening · BTC · 5% · 2024-01-01–2024-12-31');
});
it('schrijft een variabele afspraak als "variabel"', () => {
assert.equal(scheduleLabel({ ...basis, rateKind: 'variable' }),
'Spaarrekening · BTC · variabel · 2024-01-01–2024-12-31');
});
it('schrijft een lopende afspraak als "heden"', () => {
assert.ok(scheduleLabel({ ...basis, endsOn: null }).endsWith('2024-01-01–heden'));
});
it('haalt de nullen van een rond percentage weg', () => {
assert.equal(scheduleLabel({ ...basis, annualRate: 0.0725 }).split(' · ')[2], '7.25%');
});
});
describe('Valuation', () => {
it('sorteert listValuations op peildatum, nieuwste eerst', () => {
const oud = waardering({ acc: accA, onDate: '2024-01-01', balance: 1 });
const nieuw = waardering({ acc: accA, onDate: '2024-06-01', balance: 2 });
assert.deepEqual(listValuations().map(v => v.id), [nieuw, oud]);
});
it('verrijkt met account- en assetnaam', () => {
waardering({ acc: accA, onDate: '2024-01-01', balance: 1 });
const v = listValuations()[0];
assert.equal(v.account_name, 'A');
assert.equal(v.asset_symbol, 'BTC');
});
it('berekent het verschil als saldo min grootboek', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationDiff(listValuations()[0]), 200);
});
it('geeft null als de waardering geen account heeft', () => {
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationDiff(listValuations()[0]), null);
});
it('kijkt alleen naar events t/m het rateAt-moment', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: accA, amount: 5000, at: '2024-09-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1000 });
assert.equal(valuationDiff(listValuations()[0]), 0,
'de latere aankoop valt buiten het venster van deze waardering');
});
it('listOpenValuationDiffs laat reconciled waarderingen weg', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1000 });
waardering({ acc: accA, onDate: '2024-03-01', balance: 1500 });
const open = listOpenValuationDiffs();
assert.equal(open.length, 1);
assert.equal(open[0].diff, 500);
});
it('listOpenValuationDiffs laat waarderingen zonder account weg', () => {
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.deepEqual(listOpenValuationDiffs(), [],
'diff is null en null is geen openstaand verschil');
});
it('ziet een negatief verschil ook als openstaand', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 800 });
assert.equal(listOpenValuationDiffs()[0].diff, -200);
});
it('is niet verzoenbaar zonder boekingen vóór het peilmoment', () => {
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), false);
assert.equal(valuationDiff(listValuations()[0]), 1200, 'het verschil bestaat nog wél als getal');
assert.deepEqual(listOpenValuationDiffs(), [], 'maar het is geen werk');
});
it('is wél verzoenbaar met een boeking vóór het peilmoment', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), true);
assert.equal(listOpenValuationDiffs().length, 1);
});
it('kijkt voor de verzoenbaarheid naar hetzelfde venster als het verschil', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-09-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), false);
});
it('is niet verzoenbaar zonder account', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationReconcilable(listValuations()[0]), false);
});
it('telt een grootboek dat op nul uitkomt tóch als grootboek', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'sell', from: accA, amount: 1000, at: '2024-01-15T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(eventsNetByAccount(accA, btc, '2024-02-01T00:00:00'), 0, 'de som is nul');
assert.equal(valuationReconcilable(listValuations()[0]), true, 'en er is tóch een grootboek');
assert.equal(listOpenValuationDiffs().length, 1);
});
it('geeft per waardering het verschil én de verzoenbaarheid in één lijst', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
const met = waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
const zonder = waardering({ acc: accA, onDate: '2023-06-01', balance: 500 });
const perId = new Map(valuationDiffs().map(s => [s.id, s]));
assert.deepEqual(perId.get(met), { id: met, diff: 200, gap: -300, reconcilable: true, accepted: false, future: false });
assert.deepEqual(perId.get(zonder), { id: zonder, diff: 500, gap: 500, reconcilable: false, accepted: false, future: false });
});
it('haalt een AANVAARD verschil uit de werklijst en zegt het in de kolom', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
const id = waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
assert.equal(listOpenValuationDiffs().length, 1, 'vooraf staat hij op de werklijst');
harness.exports.run(`UPDATE Valuation SET gapAcceptedAt = '2024-03-01T00:00:00Z' WHERE id = ?`, [id]);
assert.deepEqual(listOpenValuationDiffs(), [], 'erna niet meer');
assert.equal(valuationDiffs().find(s => s.id === id).accepted, true, 'maar de kolom weet het wel');
assert.equal(valuationDiffs().find(s => s.id === id).gap, 200, 'en het bedrag blijft berekend');
});
});
describe('eventsCountByAccount', () => {
it('telt de boekingen van dit account voor dit asset', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'transfer', from: accA, to: accB, amount: 400, at: '2024-02-01T00:00:00' });
assert.equal(eventsCountByAccount(accA, btc, null), 2, 'in- én uitgaand tellen mee');
assert.equal(eventsCountByAccount(accB, btc, null), 1);
});
it('houdt zich aan het peilmoment', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: accA, amount: 1000, at: '2024-09-01T00:00:00' });
assert.equal(eventsCountByAccount(accA, btc, '2024-06-01T00:00:00'), 1);
});
it('telt niets voor een ander asset of een boeking zonder account', () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, decimals, displayUnit)
VALUES ('XMR','Monero','monero','crypto','XMR',8,'unit')`);
const ander = harness.exports.lastId();
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
assert.equal(eventsCountByAccount(accA, ander, null), 0, 'ander asset');
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, source, createdAt)
VALUES (?, 'buy', '2024-03-01T00:00:00', 500, 50000, 'manual', '2024-03-01T00:00:00')`, [btc]);
assert.equal(eventsCountByAccount(accA, btc, null), 1, 'een boeking zonder rekening hangt hier niet aan');
});
});
describe('waarderingen met een peildatum die nog moet komen', () => {
const morgen = new Date(Date.now() + 36 * 3600 * 1000).toISOString();
it('meldt géén openstaand verschil, ook al wijkt het van het grootboek af', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: morgen.slice(0, 10), balance: 5000, rateAt: morgen });
assert.deepEqual(listOpenValuationDiffs(), []);
});
it('markeert hem als toekomstig en rekent het verschil wél uit', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
const id = waardering({ acc: accA, onDate: morgen.slice(0, 10), balance: 5000, rateAt: morgen });
const s = valuationDiffs().find(x => x.id === id);
assert.deepEqual(s, { id, diff: 4000, gap: 4000, reconcilable: true, accepted: false, future: true });
});
it('gaat wél als werk gelden zodra het peilmoment voorbij is', () => {
const gisteren = new Date(Date.now() - 36 * 3600 * 1000).toISOString();
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: gisteren.slice(0, 10), balance: 5000, rateAt: gisteren });
assert.equal(listOpenValuationDiffs().length, 1);
});
});
describe('valuationGap', () => {
it('is gelijk aan valuationDiff zolang er geen eerdere balans is', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1200 });
const v = listValuations()[0];
assert.equal(valuationGap(v), valuationDiff(v));
assert.equal(valuationGap(v), 200);
});
it('meet vanaf de vorige balans en niet vanaf het begin van het grootboek', () => {
waardering({ acc: accA, onDate: '2024-01-01', balance: 1000 });
event({ type: 'interest', to: accA, amount: 50, at: '2024-02-01T00:00:00' });
const nieuw = waardering({ acc: accA, onDate: '2024-03-01', balance: 800 });
const v = listValuations().find(x => x.id === nieuw);
assert.equal(valuationGap(v), -250, 'wat er sinds de vorige meting ongeboekt uit is gegaan');
assert.equal(valuationDiff(v), 750, 'het oude getal, en dat is hier onbruikbaar');
});
it('laat een ongeboekt verschil niet bij élke volgende balans terugkomen', () => {
event({ type: 'buy', to: accA, amount: 1000, at: '2024-01-01T00:00:00' });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1100 });
event({ type: 'buy', to: accA, amount: 500, at: '2024-03-01T00:00:00' });
const tweede = waardering({ acc: accA, onDate: '2024-04-01', balance: 1600 });
const v = listValuations().find(x => x.id === tweede);
assert.equal(valuationGap(v), 0, 'de tweede meting klopt met de boeking ertussen');
assert.equal(valuationDiff(v), 100, 'terwijl het oude getal die 100 blijft herhalen');
});
it('kijkt naar de vorige balans van dezelfde rekening en hetzelfde asset', () => {
waardering({ acc: accA, onDate: '2024-01-01', balance: 1000 });
waardering({ acc: accB, onDate: '2024-02-01', balance: 7777 });
waardering({ acc: accA, onDate: '2024-02-01', balance: 1000, assetId: eth });
const v = listValuations().find(x => x.accountId === accA && x.assetId === btc);
assert.equal(anchorBefore(accA, btc, v.rateAt), null, 'er is er geen eerdere voor dit paar');
assert.equal(valuationGap(v), 1000);
});
it('geeft null zonder account', () => {
waardering({ acc: null, onDate: '2024-02-01', balance: 1200 });
assert.equal(valuationGap(listValuations()[0]), null);
});
});
describe('Event.priceOrigin', () => {
const payload = (extra = {}) => ({
assetId: btc, type: 'buy', occurredAt: '2026-05-01T10:00:00.000Z', amount: 100000,
fiatPrice: 50000, source: 'manual', toAccountId: accA, ...extra,
});
const rij = id => listEvents().find(e => e.id === id);
it('bewaart de herkomst en geeft hem terug op de leesweg', async () => {
const id = await createEvent(payload({ priceOrigin: 'market_day' }));
assert.equal(rij(id).priceOrigin, 'market_day');
assert.equal(listTrades().find(e => e.id === id).priceOrigin, 'market_day',
'ook via de join, want daar leest de pagina hem');
});
it('laat de herkomst NULL als niemand hem meegeeft, en dat betekent onbekend', async () => {
const id = await createEvent(payload());
assert.equal(rij(id).priceOrigin, null);
});
it('maakt van een leeg veld NULL en niet van een lege string', async () => {
const id = await createEvent(payload({ priceOrigin: '' }));
assert.equal(rij(id).priceOrigin, null);
});
it('weigert een herkomst die de enum niet kent', async () => {
await assert.rejects(() => createEvent(payload({ priceOrigin: 'guessed' })), /Invalid priceOrigin/);
assert.deepEqual(listEvents(), [], 'en er staat niets, dus de guard zit voor de INSERT');
});
it('LAAT DE HERKOMST STAAN bij een bewerking die er niet over gaat', async () => {
const id = await createEvent(payload({ priceOrigin: 'market_day' }));
await updateEvent(id, payload({ fiatFee: 12 }));
assert.equal(rij(id).priceOrigin, 'market_day', 'een bewerking elders mag dit niet wissen');
assert.equal(rij(id).fiatFee, 12, 'en de bewerking zelf is wel doorgevoerd');
});
it('wist hem WEL als de aanroeper dat expliciet zegt', async () => {
const id = await createEvent(payload({ priceOrigin: 'paid' }));
await updateEvent(id, payload({ priceOrigin: null }));
assert.equal(rij(id).priceOrigin, null);
});
});
+78
View File
@@ -0,0 +1,78 @@
// Generated, do not edit. sha256:6ad601b19ff4b76c3fb34f868abd93917ef0af58cb521f376bcb0c43c66b6827
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { createEvent, updateEvent, deleteEvent, listEvents } = await import('../../server/data/events.js');
const { createValuation, updateValuation, deleteValuation, acceptValuationGap } = await import('../../server/data/valuations.js');
const { createSchedule, updateSchedule, deleteSchedule } = await import('../../server/data/interest-schedules.js');
const { takeChanges } = await import('../../server/data/_events.js');
let btc, acc;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
btc = harness.exports.lastId();
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('A','a','wallet',1,0,1,NULL)`);
acc = harness.exports.lastId();
takeChanges();
});
const aankoop = { assetId: btc, type: 'buy', occurredAt: '2024-03-01T00:00:00Z', amount: 100_000,
fiatPrice: 30_000, toAccountId: acc, source: 'manual' };
const balans = () => ({ assetId: btc, valuedOn: '2024-04-01', accountId: acc, balance: 100_000,
fiatRate: 30_000, rateAt: '2024-03-31T22:00:00Z', source: 'manual' });
const afspraak = () => ({ accountId: acc, rateKind: 'fixed', principalAssetId: btc, payoutAssetId: btc,
principalAmount: 100_000, annualRate: 0.05, startsOn: '2024-01-01', endsOn: '2025-01-01', creditCadence: 'annual' });
const signaal = () => assert.deepEqual(takeChanges(), ['ledger-changed']);
describe('ledger-changed: boekingen', () => {
it('bij aanmaken, wijzigen en verwijderen', async () => {
const id = await createEvent({ ...aankoop, assetId: btc, toAccountId: acc });
signaal();
await updateEvent(id, { ...aankoop, assetId: btc, toAccountId: acc, amount: 200_000 });
signaal();
await deleteEvent(id);
signaal();
});
it('niet bij een geweigerde boeking, want er veranderde niets', async () => {
await assert.rejects(() => createEvent({ ...aankoop, assetId: btc, toAccountId: acc, fiatPrice: null }));
assert.deepEqual(takeChanges(), []);
});
it('niet bij lezen', () => {
listEvents();
assert.deepEqual(takeChanges(), []);
});
});
describe('ledger-changed: balansen', () => {
it('bij aanmaken, wijzigen, aanvaarden en verwijderen', async () => {
const id = await createValuation(balans());
signaal();
await updateValuation(id, { ...balans(), balance: 150_000 });
signaal();
await acceptValuationGap(id);
signaal();
await deleteValuation(id);
signaal();
});
});
describe('ledger-changed: rente-afspraken', () => {
it('bij aanmaken, wijzigen en verwijderen, want begin en einde staan op de activiteitslijn', async () => {
const id = await createSchedule(afspraak());
signaal();
await updateSchedule(id, { ...afspraak(), annualRate: 0.04 });
signaal();
await deleteSchedule(id);
signaal();
});
});
+29
View File
@@ -0,0 +1,29 @@
// Generated, do not edit. sha256:b419fb671fda156e789a3b8925ad11b622c94e6d19d9c264f032e72b6dbf961c
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { hygieneReport, hygieneKinds, acceptHygieneFinding } = await import('../../server/data/hygiene.js');
const { healthChecks } = await import('../../server/registers.mjs');
beforeEach(() => harness.recreate());
describe('een geregistreerde controle', () => {
it('staat in de soorten, levert een bevinding, en is te aanvaarden', async () => {
assert.equal(hygieneKinds().some(k => k.kind === 'test_register'), false);
healthChecks.register({ id: 'test_register', severity: 'info', countsFrom: [], run: () => [{ row: { id: 1 }, counts: {} }] });
assert.ok(hygieneKinds().some(k => k.kind === 'test_register' && k.severity === 'info'));
const eigen = () => hygieneReport({ kinds: ['test_register'] }).findings[0];
assert.equal(eigen().total, 1);
await acceptHygieneFinding({ kind: 'test_register', row: { id: 1 }, counts: {}, summary: 'gezien' });
assert.equal(eigen().total, 0, 'een aanvaarde bevinding valt weg, ook bij een geregistreerde controle');
});
it('mag niet dezelfde soort hebben als een kerncontrole', () => {
healthChecks.register({ id: 'duplicate_events', severity: 'warn', countsFrom: [], run: () => [] });
assert.throws(() => hygieneKinds(), /also a core check/);
});
});
+202
View File
@@ -0,0 +1,202 @@
// Generated, do not edit. sha256:fc183f4c5a327b7b47dcb1a430409a48861cde95adfddadca5231c79a33dc477
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { recordIntegrationCall, listIntegrationCalls, lastIntegrationFailures, countServiceCall, serviceState } =
await import('../../server/data/integration-log.js');
beforeEach(() => harness.recreate());
const rijen = () => harness.exports.all(`SELECT * FROM IntegrationCall ORDER BY id`);
const dagenGeleden = (d) => new Date(Date.now() - d * 86_400_000).toISOString();
const fout = (extra = {}) => ({
target: 'provider', providerKey: 'goldapi', operation: 'spot', subject: 'XAU', outcome: 'fail',
httpStatus: 403, errorKey: 'integrations.price.httpFailed', detail: 'Forbidden', latencyMs: 120, ...extra,
});
function oudeRij(occurredAt, providerKey = 'goldapi') {
harness.exports.run(
`INSERT INTO IntegrationCall (target, providerKey, operation, outcome, occurredAt) VALUES ('provider', ?, 'spot', 'fail', ?)`,
[providerKey, occurredAt]);
}
describe('recordIntegrationCall: wat er nooit in mag', () => {
it('vervangt een sleutel die de bron in zijn melding herhaalt, ook URL-gecodeerd', async () => {
const sleutel = 'goldapi-abc+123/xyz';
await recordIntegrationCall(fout({ detail: `Invalid key ${sleutel} (${encodeURIComponent(sleutel)})` }),
{ secrets: [sleutel] });
const [r] = rijen();
assert.ok(!r.detail.includes(sleutel), r.detail);
assert.ok(!r.detail.includes(encodeURIComponent(sleutel)), r.detail);
assert.match(r.detail, /\*\*\*/);
});
it('vervangt het token van de dienst in een Bearer-waarde, ook zonder dat het token is meegegeven', async () => {
await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail',
detail: 'echo Authorization: Bearer s3cr3t-token-waarde' });
assert.equal(rijen()[0].detail, 'echo Authorization: Bearer ***');
});
it('laat geen URL staan, want bij query_key staat de sleutel in de query', async () => {
await recordIntegrationCall(fout({ detail: 'GET https://api.example.test/v1/price?apikey=K123456 failed' }));
const [r] = rijen();
assert.ok(!/https?:|K123456|example\.test/.test(r.detail), r.detail);
assert.match(r.detail, /<url>/);
});
it('vervangt een sleutelparameter in losse tekst', async () => {
await recordIntegrationCall(fout({ detail: 'rejected token=abcdef1234 and api_key=zzz999' }));
assert.equal(rijen()[0].detail, 'rejected token=*** and api_key=***');
});
it('weigert een URL als operatie in plaats van hem op te schonen', async () => {
await assert.rejects(recordIntegrationCall(fout({ operation: 'https://api.example.test/spot?key=x' })),
/operation is a path or a capability/);
await assert.rejects(recordIntegrationCall(fout({ operation: '/v1/spot?instrument=x' })),
/operation is a path or a capability/);
assert.equal(rijen().length, 0);
});
it('kort een lange melding in tot de grens, zichtbaar', async () => {
await recordIntegrationCall(fout({ detail: 'x'.repeat(1000) }));
const { detail } = rijen()[0];
assert.equal(detail.length, CONFIG.INTEGRATION_LOG.DETAIL_MAX_CHARS);
assert.ok(detail.endsWith('…'));
});
it('bewaart in errorKey alleen sleutels en geen vrije tekst uit een antwoord', async () => {
await recordIntegrationCall({ target: 'service', operation: '/v1/series', outcome: 'fail',
errorKey: 'marketService.rejected api.noSeries <script>alert(1)</script>' });
assert.equal(rijen()[0].errorKey, 'marketService.rejected api.noSeries');
});
it('bewaart een request-id alleen bij de dienst en alleen in de afgesproken vorm', async () => {
await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail', requestId: 'a1b2c3d4-0000-4000-8000-000000000000' });
await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail', requestId: 'kort' });
await recordIntegrationCall(fout({ requestId: 'a1b2c3d4-0000-4000-8000-000000000000' }));
assert.deepEqual(rijen().map(r => r.requestId), ['a1b2c3d4-0000-4000-8000-000000000000', null, null]);
});
});
describe('recordIntegrationCall: contractguards', () => {
it('weigert een onbekende soort en een onbekende uitkomst, ook "ok"', async () => {
await assert.rejects(recordIntegrationCall(fout({ target: 'lnd' })), /Unknown integration target/);
await assert.rejects(recordIntegrationCall(fout({ outcome: 'ok' })), /Unknown integration outcome/);
assert.equal(rijen().length, 0);
});
it('eist een providerKey bij een provider en negeert hem bij de dienst', async () => {
await assert.rejects(recordIntegrationCall(fout({ providerKey: '' })), /needs a providerKey/);
await recordIntegrationCall({ target: 'service', providerKey: 'goldapi', operation: '/v1/spot', outcome: 'refused' });
assert.equal(rijen()[0].providerKey, null);
});
it('legt het tijdstip van versturen vast tot op de milliseconde', async () => {
await recordIntegrationCall(fout({ occurredAt: Date.UTC(2026, 9, 19, 8, 30, 15, 123) }));
assert.equal(rijen()[0].occurredAt, '2026-10-19T08:30:15.123Z');
});
});
describe('opruimen bij elke schrijfactie', () => {
it('haalt rijen ouder dan de termijn weg en laat rijen binnen de termijn staan', async () => {
const dagen = CONFIG.INTEGRATION_LOG.RETENTION_DAYS;
oudeRij(dagenGeleden(dagen + 1), 'oud');
oudeRij(dagenGeleden(dagen - 1), 'binnen');
await recordIntegrationCall(fout());
assert.deepEqual(rijen().map(r => r.providerKey), ['binnen', 'goldapi']);
});
it('houdt het plafond aan door de oudste rijen weg te halen', async () => {
const max = CONFIG.INTEGRATION_LOG.MAX_ROWS;
const nu = new Date().toISOString();
harness.exports.run(`WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ?)
INSERT INTO IntegrationCall (target, providerKey, operation, outcome, occurredAt)
SELECT 'provider', 'p' || i, 'spot', 'fail', ? FROM n`, [max, nu]);
await recordIntegrationCall(fout());
const { n } = harness.exports.one(`SELECT COUNT(*) AS n FROM IntegrationCall`);
assert.equal(n, max);
assert.ok(!harness.exports.one(`SELECT 1 AS x FROM IntegrationCall WHERE providerKey = 'p1'`));
assert.ok(harness.exports.one(`SELECT 1 AS x FROM IntegrationCall WHERE providerKey = 'p2'`));
assert.ok(harness.exports.one(`SELECT 1 AS x FROM IntegrationCall WHERE providerKey = 'goldapi'`));
});
});
describe('countServiceCall: de dienst in ApiCallMetric', () => {
const tellingen = () => harness.exports.all(`SELECT capability, okCount, failCount FROM ApiCallMetric`);
it('telt onder de systeemprovider van de dienst', async () => {
harness.exports.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-19')`);
await countServiceCall({ capability: 'spot', ok: true, latencyMs: 30 });
await countServiceCall({ capability: 'spot', ok: false, latencyMs: 10 });
assert.deepEqual(tellingen(), [{ capability: 'spot', okCount: 1, failCount: 1 }]);
});
it('telt niets zonder die rij, in plaats van te gooien', async () => {
await countServiceCall({ capability: 'spot', ok: true, latencyMs: 30 });
assert.deepEqual(tellingen(), []);
});
});
describe('serviceState: het oordeel van het lampje', () => {
const dienst = () => harness.exports.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-19')`);
const gelukt = (minuut) => harness.exports.run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs)
SELECT id, 'spot', ?, 1, 0, 10, 10 FROM Provider WHERE key = 'atreides_services'`, [minuut]);
const mislukt = (at, outcome = 'fail') => recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome, occurredAt: at });
it('zonder log en zonder teller: geen oordeel', () => {
dienst();
assert.deepEqual({ ...serviceState(), lastFail: null }, { ok: null, at: null, lastFail: null, lastOkAt: null });
});
it('een fout na het laatste succes is rood, ook in dezelfde minuut', async () => {
dienst();
gelukt('2026-10-19T08:00');
await mislukt('2026-10-19T08:00:30.000Z');
assert.deepEqual([serviceState().ok, serviceState().at], [false, '2026-10-19T08:00:30.000Z']);
});
it('een succes na de laatste fout is groen', async () => {
dienst();
await mislukt('2026-10-19T08:00:30.000Z');
gelukt('2026-10-19T08:01');
assert.deepEqual([serviceState().ok, serviceState().at], [true, '2026-10-19T08:01:00.000Z']);
});
it('een leeg antwoord maakt het lampje niet rood', async () => {
dienst();
gelukt('2026-10-19T08:00');
await mislukt('2026-10-19T08:05:00.000Z', 'empty');
assert.equal(serviceState().ok, true);
});
});
describe('lezen', () => {
it('listIntegrationCalls: nieuwste eerst, binnen het venster, te filteren', async () => {
oudeRij(dagenGeleden(10), 'buiten');
await recordIntegrationCall(fout({ providerKey: 'a' }));
await recordIntegrationCall({ target: 'service', operation: '/v1/series', outcome: 'empty' });
await recordIntegrationCall(fout({ providerKey: 'b' }));
assert.deepEqual(listIntegrationCalls().map(r => r.providerKey), ['b', null, 'a']);
assert.deepEqual(listIntegrationCalls({ target: 'service' }).map(r => r.operation), ['/v1/series']);
assert.deepEqual(listIntegrationCalls({ providerKey: 'a' }).length, 1);
assert.deepEqual(listIntegrationCalls({ days: 30 }).map(r => r.providerKey), ['b', null, 'a', 'buiten']);
assert.deepEqual(listIntegrationCalls({ limit: 1 }).map(r => r.providerKey), ['b']);
});
it('lastIntegrationFailures: per bron de laatste, de dienst als eigen bron', async () => {
await recordIntegrationCall(fout({ providerKey: 'a', detail: 'eerste' }));
await recordIntegrationCall({ target: 'service', operation: '/v1/spot', outcome: 'fail', detail: 'dienst' });
await recordIntegrationCall(fout({ providerKey: 'a', detail: 'tweede' }));
assert.deepEqual(lastIntegrationFailures().map(r => [r.providerKey, r.detail]),
[['a', 'tweede'], [null, 'dienst']]);
});
});
+94
View File
@@ -0,0 +1,94 @@
// Generated, do not edit. sha256:6f5b8502746b6ae69cfa949fb746f614f73c7a3a69761281686d9078e5f7285a
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { listProviders, getProvider } = await import('../../server/data/providers.js');
const { listAssets } = await import('../../server/data/assets.js');
const { integrationsJson, INTEGRATIONS_FORMAT } = await import('../../server/integrations-export.mjs');
const { run, lastId } = harness.exports;
function provider(key, { system = false, auth = 'none', required = 0, header = null, param = null } = {}) {
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, authenticationHeaderName,
authenticationParameterName, isActive, isSeed, isSystem, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, '2026-10-10')`,
[key, key, key, system ? '' : `https://${key}.example`, auth, required, header, param, system ? 1 : 0]);
return lastId();
}
const endpoint = (id, capability, path, query = null, quote = null, interval = null) =>
run(`INSERT INTO ProviderEndpoint (providerId, pathTemplate, queryTemplate, capability, method, quoteCurrency, minIntervalSeconds)
VALUES (?, ?, ?, ?, 'GET', ?, ?)`, [id, path, query, capability, quote, interval]);
const output = (id, capability, field, path, transform) =>
run(`INSERT INTO ProviderOutputMap (providerId, jsonPath, capability, internalField, transform) VALUES (?, ?, ?, ?, ?)`, [id, path, capability, field, transform]);
const symbol = (id, sym, externalId, { catchAll = false, unit = null } = {}) =>
run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll) VALUES (?, ?, ?, ?, ?)`,
[id, catchAll ? null : sym, externalId, unit, catchAll ? 1 : 0]);
const covers = (id, ...syms) => { for (const s of syms) run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, ?)`, [id, s]); };
const asset = (sym, kind) => run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?, ?, ?, ?, ?, NULL, 8, 'unit')`, [sym, sym, sym.toLowerCase(), kind, sym]);
const exported = (base = 'EUR') => integrationsJson(listProviders().map(p => getProvider(p.key)), listAssets(), base);
beforeEach(() => {
harness.recreate();
for (const k of ['manual', 'atreides_services', 'fixed']) provider(k, { system: true });
asset('XAU', 'metal');
asset('LINK', 'crypto');
const gold = provider('goldapi', { auth: 'header_key', required: 1, header: 'x-access-token' });
endpoint(gold, 'spot', '/api/{symbol}/EUR', null, 'EUR', 3600);
endpoint(gold, 'history_point', '/api/{symbol}/EUR/{date}', null, 'EUR');
output(gold, 'spot', 'price', 'price', 'troy_ounce_to_gram');
covers(gold, 'XAU');
run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth) VALUES (?, 100)`, [gold]);
const euler = provider('eulerpool', { auth: 'query_key', required: 1, param: 'token' });
endpoint(euler, 'daily_history', '/api/1/{external_id}/quotes/{symbol}', null, 'USD');
symbol(euler, null, 'crypto', { catchAll: true });
symbol(euler, 'XAU', 'commodity', { unit: 'ozt' });
covers(euler, 'LINK', 'XAU', 'NOASSET');
const bitvavo = provider('bitvavo', { auth: 'hmac' });
run(`INSERT INTO ProviderAuthenticationHmac (providerId, messageParts, keyHeader, signatureHeader, timestampHeader, windowHeader,
windowMs, hashAlgorithm, encoding) VALUES (?, 'timestamp,method,full_path,body', 'K', 'S', 'T', 'W', 10000, 'SHA-256', 'hex')`, [bitvavo]);
endpoint(bitvavo, 'spot', '/v2/ticker/price', 'market={symbol}-EUR', null);
covers(bitvavo, 'LINK');
});
describe('integrationsJson', () => {
it('carries every source with all it has, and no system provider', () => {
const e = exported();
assert.equal(e.format, INTEGRATIONS_FORMAT);
assert.deepEqual(e.providers.map(p => p.key), ['bitvavo', 'eulerpool', 'goldapi']);
const gold = e.providers.find(p => p.key === 'goldapi');
assert.deepEqual(gold.auth, { kind: 'header_key', required: true, headerName: 'x-access-token', parameterName: null, hmac: null });
assert.equal(gold.callsPerMonth, 100);
assert.deepEqual(gold.endpoints.map(x => [x.capability, x.pathTemplate, x.minIntervalSeconds]),
[['history_point', '/api/{symbol}/EUR/{date}', null], ['spot', '/api/{symbol}/EUR', 3600]]);
assert.deepEqual(gold.outputs, [{ capability: 'spot', internalField: 'price', jsonPath: 'price', transform: 'troy_ounce_to_gram' }]);
});
it('keeps the HMAC recipe and the catch-all, which only the reader can judge', () => {
const e = exported();
assert.equal(e.providers.find(p => p.key === 'bitvavo').auth.hmac.messageParts, 'timestamp,method,full_path,body');
assert.deepEqual(e.providers.find(p => p.key === 'eulerpool').inputMap, [
{ symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt', isCatchAll: false },
{ symbol: null, externalId: 'crypto', quoteUnit: null, isCatchAll: true },
]);
});
it('fills in the unit of account for a NULL quote, and the kind of a covered symbol', () => {
const bitvavo = exported('USD').providers.find(p => p.key === 'bitvavo');
assert.equal(bitvavo.endpoints[0].quote, 'USD');
assert.deepEqual(exported().providers.find(p => p.key === 'eulerpool').coverage,
[{ symbol: 'LINK', kind: 'crypto' }, { symbol: 'NOASSET', kind: null }, { symbol: 'XAU', kind: 'metal' }]);
});
it('carries no secret and gives the same file twice', () => {
const text = JSON.stringify(exported());
assert.ok(!/api_key|api_secret|secret"/i.test(text), 'a secret field is in the export');
assert.equal(JSON.stringify(exported()), text);
});
});
+722
View File
@@ -0,0 +1,722 @@
// Generated, do not edit. sha256:e9ccb4b4784d24c1c464248bf37dc50c0e76a1e76874e1d0edd11a9b9edf6572
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import { toUtcIso } from '../../shared/time.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { createAsset } = await import('../../server/data/assets.js');
const { dueCreditsForAccount, realizedInterestSub, scheduleForecast, bookDueCredit, dismissDueCredit } = await import('../../server/data/interest-credits.js');
const { createSchedule, createPeriod, listPeriods, updatePeriod, deletePeriod,
listSchedulesForAccount, listInterest, realizedForSchedule } = await import('../../server/data/interest-schedules.js');
const SATS = 8;
const EEN_BTC = 100_000_000;
let ctx;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, ['BTC', 'Bitcoin', toSearchName('Bitcoin'), 'crypto', 'BTC', 'sat', SATS, 'subunit']);
const assetId = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, ['Rentekening', toSearchName('Rentekening'), 'bank', 0, 0]);
const accountId = harness.exports.lastId();
ctx = { assetId, accountId };
});
const afspraak = (extra) => ({
accountId: ctx.accountId,
rateKind: 'fixed',
principalAssetId: ctx.assetId,
payoutAssetId: ctx.assetId,
principalAmount: EEN_BTC,
principal_decimals: SATS,
payout_decimals: SATS,
annualRate: 0.05,
creditWeekday: null,
creditDayOfMonth: null,
...extra,
});
function renteEvent(dagIso, bedragSub) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?)`,
[ctx.assetId, 'interest', `${dagIso}T00:00:00`, bedragSub, ctx.accountId, 'manual', '2026-01-01T00:00:00']);
}
describe('crediteringsmomenten per cadans', () => {
const aantalPerioden = (extra) => {
const s = afspraak(extra);
return scheduleForecast(s, s.startsOn).remainingCount;
};
it('term_end levert één periode over de hele looptijd', () => {
assert.equal(aantalPerioden({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2026-12-31',
}), 1);
});
it('annual rekent per jaarultimo (1 januari van het volgende jaar)', () => {
assert.equal(aantalPerioden({
creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2023-01-01',
}), 3);
});
it('annual sluit af op de einddatum en niet op een ultimo erna', () => {
assert.equal(aantalPerioden({
creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2022-12-31',
}), 3);
});
it('schrijft het laatste stuk van een deposito bij op de einddatum', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0.028, startsOn: '2015-06-15', endsOn: '2019-06-15',
}));
const dues = dueCreditsForAccount(ctx.accountId);
const laatste = dues.at(-1);
assert.deepEqual([laatste.periodStart, laatste.periodEnd], ['2019-01-01', '2019-06-15']);
assert.equal(laatste.projected_sub, Math.round(EEN_BTC * 0.028 * 165 / 365));
const s = listSchedulesForAccount(ctx.accountId)[0];
const totaal = scheduleForecast(s, '2015-06-15').termTotalSub;
assert.ok(Math.abs(totaal - EEN_BTC * 0.028 * 1461 / 365) <= dues.length, `termijntotaal ${totaal}`);
});
it('quarterly rekent per KALENDERkwartaal en niet per drie maanden vanaf de start', () => {
assert.equal(aantalPerioden({
creditCadence: 'quarterly', startsOn: '2024-02-15', endsOn: '2025-01-01',
}), 4);
});
it('quarterly sluit af op de einddatum en niet op een kwartaal erna', () => {
assert.equal(aantalPerioden({
creditCadence: 'quarterly', startsOn: '2024-02-15', endsOn: '2024-09-30',
}), 3);
});
it('quarterly begint óp een kwartaalgrens zonder een periode van nul dagen', () => {
assert.equal(aantalPerioden({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: '2025-01-01',
}), 4);
});
it('monthly slaat de startdag over en neemt de einddatum wél mee', () => {
assert.equal(aantalPerioden({
creditCadence: 'monthly', creditDayOfMonth: 15,
startsOn: '2024-01-15', endsOn: '2024-04-15',
}), 3);
});
it('weekly begint bij het eerste voorkomen strikt ná de start', () => {
assert.equal(aantalPerioden({
creditCadence: 'weekly', creditWeekday: 1,
startsOn: '2024-01-01', endsOn: '2024-01-29',
}), 4);
});
it('loopt zonder einddatum door tot vandaag', () => {
const s = afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null });
const tot = scheduleForecast(s, '2024-06-15');
assert.equal(tot.termTotalSub, null, 'geen termijntotaal zonder einddatum');
assert.equal(tot.remainingCount, null);
assert.equal(dueCreditsForAccount(ctx.accountId).length, 0, 'deze afspraak staat nog niet in de database');
});
it('geeft zonder einddatum wél te-boeken bijschrijvingen', async () => {
await createSchedule(afspraak({
creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null,
}));
const due = dueCreditsForAccount(ctx.accountId);
assert.ok(due.length >= 12, `verwacht minstens een jaar aan maandmomenten, kreeg ${due.length}`);
assert.ok(due.every(d => d.periodEnd <= new Date().toISOString().slice(0, 10)),
'geen crediteringsmoment dat nog moet komen');
assert.ok(due.every(d => d.periodEnd.endsWith('-01')), 'alleen maandmomenten, geen stuk tot vandaag');
});
it('remainingCount telt alleen perioden ná de peildatum', () => {
const s = afspraak({
creditCadence: 'monthly', creditDayOfMonth: 15,
startsOn: '2024-01-15', endsOn: '2024-04-15',
});
assert.equal(scheduleForecast(s, '2024-01-15').remainingCount, 3);
assert.equal(scheduleForecast(s, '2024-02-15').remainingCount, 2);
assert.equal(scheduleForecast(s, '2024-04-15').remainingCount, 0);
});
});
describe('crediteringsdatums (via de due-lijst)', () => {
const eersteDues = async (extra, n) => {
await createSchedule(afspraak({ endsOn: '2099-12-31', ...extra }));
return dueCreditsForAccount(ctx.accountId).slice(0, n);
};
it('klemt dag 31 op de laatste dag van de maand, inclusief schrikkeljaar', async () => {
const dues = await eersteDues({
creditCadence: 'monthly', creditDayOfMonth: 31, startsOn: '2024-01-31',
}, 3);
assert.deepEqual(dues.map(d => d.periodEnd), ['2024-02-29', '2024-03-31', '2024-04-30']);
});
it('klemt februari op 28 in een niet-schrikkeljaar', async () => {
const dues = await eersteDues({
creditCadence: 'monthly', creditDayOfMonth: 31, startsOn: '2023-01-31',
}, 2);
assert.deepEqual(dues.map(d => d.periodEnd), ['2023-02-28', '2023-03-31']);
});
it('perioden sluiten op elkaar aan en beginnen bij de startdatum', async () => {
const dues = await eersteDues({
creditCadence: 'monthly', creditDayOfMonth: 10, startsOn: '2024-05-10',
}, 4);
assert.equal(dues[0].periodStart, '2024-05-10');
for (let i = 1; i < dues.length; i++) {
assert.equal(dues[i].periodStart, dues[i - 1].periodEnd,
'een periode moet beginnen waar de vorige eindigde, anders valt er rente tussen');
}
});
it('wekelijkse momenten liggen zeven dagen uit elkaar op de gekozen weekdag', async () => {
const dues = await eersteDues({
creditCadence: 'weekly', creditWeekday: 3, startsOn: '2024-01-01',
}, 3);
assert.deepEqual(dues.map(d => d.periodEnd), ['2024-01-03', '2024-01-10', '2024-01-17']);
});
it("geeft geen due's voor een variabele afspraak", async () => {
await createSchedule({
accountId: ctx.accountId, rateKind: 'variable', payoutAssetId: ctx.assetId,
principalAssetId: null, principalAmount: null, annualRate: null,
startsOn: '2024-01-01', endsOn: '2099-12-31',
creditCadence: null, creditWeekday: null, creditDayOfMonth: null,
});
assert.deepEqual(dueCreditsForAccount(ctx.accountId), []);
});
it("blijft de bijschrijvingen van een afgelopen afspraak aanbieden", async () => {
await createSchedule(afspraak({
creditCadence: 'monthly', creditDayOfMonth: 1,
startsOn: '2020-01-01', endsOn: '2020-12-31',
}));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 12, 'elf maandmomenten en de afsluiting, allemaal nog te boeken');
assert.equal(dues.at(-1).periodEnd, '2020-12-31', 'en niets ná de einddatum');
});
it('biedt de rente van een verstreken deposito precies één keer aan', async () => {
const eind = new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString().slice(0, 10);
const start = new Date(Date.now() - 370 * 24 * 3600 * 1000).toISOString().slice(0, 10);
await createSchedule(afspraak({ creditCadence: 'term_end', startsOn: start, endsOn: eind }));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 1);
assert.equal(dues[0].periodEnd, eind);
assert.ok(dues[0].projected_sub > 0, 'met een bedrag erbij, anders is er niets te boeken');
});
});
describe('due-detectie en dubbeltelling', () => {
async function maandelijkseAfspraak() {
const id = await createSchedule(afspraak({
creditCadence: 'monthly', creditDayOfMonth: 15,
startsOn: '2024-01-15', endsOn: '2099-12-31',
}));
return id;
}
it('een geboekte credit haalt precies die periode van de lijst', async () => {
const scheduleId = await maandelijkseAfspraak();
const voor = dueCreditsForAccount(ctx.accountId);
const eerste = voor[0];
harness.exports.run(
`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt)
VALUES (?,?,?, 'booked', ?)`,
[scheduleId, eerste.periodStart, eerste.periodEnd, '2026-01-01T00:00:00']);
const na = dueCreditsForAccount(ctx.accountId);
assert.equal(na.length, voor.length - 1);
assert.ok(!na.some(d => d.periodEnd === eerste.periodEnd));
});
it('een genegeerde credit haalt de periode ook van de lijst', async () => {
const scheduleId = await maandelijkseAfspraak();
const eerste = dueCreditsForAccount(ctx.accountId)[0];
harness.exports.run(
`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt)
VALUES (?,?,?, 'dismissed', ?)`,
[scheduleId, eerste.periodStart, eerste.periodEnd, '2026-01-01T00:00:00']);
assert.ok(!dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd));
});
it('rente die al via import binnenkwam onderdrukt de due (geen dubbeltelling)', async () => {
await maandelijkseAfspraak();
const eerste = dueCreditsForAccount(ctx.accountId)[0];
renteEvent(eerste.periodEnd, 12_345);
assert.ok(!dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd),
'een uitkering óp de crediteringsdag hoort bij de periode die dan eindigt');
});
it('een uitkering op de startdag van een periode onderdrukt die periode NIET', async () => {
await maandelijkseAfspraak();
const eerste = dueCreditsForAccount(ctx.accountId)[0];
renteEvent(eerste.periodStart, 12_345);
assert.ok(dueCreditsForAccount(ctx.accountId).some(d => d.periodEnd === eerste.periodEnd));
});
});
describe('bedragen: de prognose is de som van de bijschrijvingen', () => {
it('rekent één vol jaar op 5% van 1 BTC als 0,05 BTC', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 5_000_000);
});
it('rekent enkelvoudig over meerdere jaren, net als de bijschrijving', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2025-12-31',
});
assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 10_000_000);
});
it('telt op tot precies wat de te-boeken-lijst aanbiedt', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0.028, startsOn: '2020-03-01', endsOn: '2023-01-01',
}));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 3);
const s = listSchedulesForAccount(ctx.accountId)[0];
assert.equal(scheduleForecast(s, '2020-03-01').termTotalSub, dues.reduce((n, d) => n + d.projected_sub, 0));
});
it('trekt al ontvangen rente af van het resterende bedrag', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
renteEvent('2024-06-01', 2_000_000);
const f = scheduleForecast(s, '2024-12-31');
assert.equal(f.realizedSub, 2_000_000);
assert.equal(f.remainingSub, 3_000_000);
});
it('laat het resterende bedrag niet onder nul zakken', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
renteEvent('2024-06-01', 9_999_999);
assert.equal(scheduleForecast(s, '2024-12-31').remainingSub, 0);
});
it('merkt op wanneer hoofdsom en uitkering hetzelfde asset zijn', () => {
const s = afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31',
});
assert.equal(scheduleForecast(s, '2024-01-01').sameAsset, true);
assert.equal(scheduleForecast({ ...s, principalAssetId: 999 }, '2024-01-01').sameAsset, false);
});
});
describe('komende bijschrijvingen', () => {
it('noemt elke resterende periode met zijn bedrag, de lopende inbegrepen', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' });
const { upcoming } = scheduleForecast(s, '2025-06-15');
assert.deepEqual(upcoming, [
{ periodStart: '2025-01-01', periodEnd: '2026-01-01', projectedSub: 5_000_000 },
{ periodStart: '2026-01-01', periodEnd: '2027-01-01', projectedSub: 5_000_000 },
]);
});
it('laat een periode die op de peildatum eindigt weg, want die staat bij te boeken', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' });
assert.deepEqual(scheduleForecast(s, '2026-01-01').upcoming.map(p => p.periodEnd), ['2027-01-01']);
});
it('is leeg na de einddatum', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' });
assert.deepEqual(scheduleForecast(s, '2027-01-01').upcoming, []);
});
it('geeft zonder einddatum alleen de volgende, en die ligt ná vandaag', () => {
const s = afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2024-01-01', endsOn: null });
const f = scheduleForecast(s, '2024-06-15');
assert.deepEqual(f.upcoming, [
{ periodStart: '2024-06-01', periodEnd: '2024-07-01', projectedSub: Math.round(EEN_BTC * 0.05 * 30 / 365) },
]);
assert.equal(f.termTotalSub, null);
});
it('vindt zonder einddatum ook de volgende jaarbijschrijving', () => {
const s = afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: null });
assert.deepEqual(scheduleForecast(s, '2024-03-02').upcoming.map(p => p.periodEnd), ['2025-01-01']);
assert.deepEqual(scheduleForecast(s, '2025-01-01').upcoming.map(p => p.periodEnd), ['2026-01-01']);
});
it('telt een rentewijziging mee in het bedrag van de komende periode', async () => {
const id = await createSchedule(afspraak({ creditCadence: 'annual', startsOn: '2024-03-01', endsOn: '2027-01-01' }));
await createPeriod(id, { startsOn: '2025-07-01', annualRate: 0.01 });
const s = listSchedulesForAccount(ctx.accountId)[0];
assert.equal(scheduleForecast(s, '2025-06-15').upcoming[0].projectedSub,
Math.round(EEN_BTC * (0.05 * 181 / 365 + 0.01 * 184 / 365)));
});
});
describe('rente op rente', () => {
const drieJaar = (extra) => afspraak({
creditCadence: 'annual', startsOn: '2021-01-01', endsOn: '2024-01-01', ...extra,
});
it('rekent elke periode ook over de bijschrijvingen ervóór', () => {
const f = scheduleForecast(drieJaar({ compounding: 1 }), '2021-01-01');
assert.deepEqual(f.upcoming.map(p => p.projectedSub), [5_000_000, 5_250_000, 5_512_500]);
assert.equal(f.termTotalSub, 15_762_500);
});
it('rekent zonder de keuze enkelvoudig, zoals voorheen', () => {
const f = scheduleForecast(drieJaar({ compounding: 0 }), '2021-01-01');
assert.deepEqual(f.upcoming.map(p => p.projectedSub), [5_000_000, 5_000_000, 5_000_000]);
});
it('biedt op te boeken dezelfde bedragen aan als de prognose', async () => {
await createSchedule(drieJaar({ compounding: 1 }));
assert.deepEqual(dueCreditsForAccount(ctx.accountId).map(d => d.projected_sub), [5_000_000, 5_250_000, 5_512_500]);
});
it('telt een genegeerde bijschrijving gewoon mee, want de bank schrijft hem toch bij', async () => {
const id = await createSchedule(drieJaar({ compounding: 1 }));
harness.exports.run(
`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, status, createdAt)
VALUES (?, '2021-01-01', '2022-01-01', 'dismissed', '2026-01-01T00:00:00')`, [id]);
assert.deepEqual(dueCreditsForAccount(ctx.accountId).map(d => d.projected_sub), [5_250_000, 5_512_500]);
});
it('weigert rente op rente op een hoofdsom die het saldo volgt', async () => {
await assert.rejects(() => createSchedule(drieJaar({ compounding: 1, principalAmount: null })),
(e) => e.key === 'interest.compoundingNeedsPrincipal');
});
it('weigert rente op rente als de rente in een ander asset uitkeert', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`,
['EUR', 'Euro', toSearchName('Euro'), 'fiat', 'EUR', 'cent', 2, 'unit']);
const eur = harness.exports.lastId();
await assert.rejects(() => createSchedule(drieJaar({ compounding: 1, payoutAssetId: eur })),
(e) => e.key === 'interest.compoundingNeedsSameAsset');
});
it('bewaart de keuze en geeft hem terug', async () => {
await createSchedule(drieJaar({ compounding: 1 }));
assert.equal(listSchedulesForAccount(ctx.accountId)[0].compounding, 1);
});
});
describe('realizedInterestSub', () => {
it('telt vanaf `from` tot en met de dag vóór `to`', () => {
renteEvent('2024-03-01', 100);
renteEvent('2024-03-31', 200);
const som = realizedInterestSub(ctx.accountId, ctx.assetId, '2024-03-01', '2024-03-31');
assert.equal(som, 100, 'de bovengrens hoort er niet bij te vallen');
});
it('is 0 zonder rente-events', () => {
assert.equal(realizedInterestSub(ctx.accountId, ctx.assetId, '2024-01-01', '2025-01-01'), 0);
});
it('telt alleen rente van het gevraagde account en asset', () => {
renteEvent('2024-03-01', 100);
assert.equal(realizedInterestSub(ctx.accountId + 99, ctx.assetId, '2024-01-01', '2025-01-01'), 0);
assert.equal(realizedInterestSub(ctx.accountId, ctx.assetId + 99, '2024-01-01', '2025-01-01'), 0);
});
});
describe('ontvangen per afspraak', () => {
const tweeAfspraken = async () => {
await createSchedule(afspraak({ creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', note: 'deposito' }));
await createSchedule(afspraak({ creditCadence: 'annual', principalAmount: null, startsOn: '2024-01-01', endsOn: null, note: 'spaar' }));
const lijst = listSchedulesForAccount(ctx.accountId);
return { deposito: lijst.find(s => s.note === 'deposito'), spaar: lijst.find(s => s.note === 'spaar') };
};
it('telt één rente-boeking onder twee overlappende afspraken maar één keer', async () => {
const { deposito, spaar } = await tweeAfspraken();
renteEvent('2024-06-01', 2_000_000);
const som = scheduleForecast(deposito, '2024-12-31').realizedSub + scheduleForecast(spaar, '2024-12-31').realizedSub;
assert.equal(som, 2_000_000, 'opgeteld over de afspraken is het ontvangen bedrag er één keer');
assert.equal(realizedForSchedule(spaar, '2024-12-31'), 2_000_000);
});
it('rekent een geboekte bijschrijving toe aan de afspraak waaronder hij geboekt is', async () => {
const { deposito, spaar } = await tweeAfspraken();
renteEvent('2024-06-01', 2_000_000);
const eventId = harness.exports.lastId();
harness.exports.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?,?, 'booked', ?)`, [deposito.id, '2024-01-01', '2024-06-01', eventId, '2024-06-01T00:00:00']);
assert.equal(scheduleForecast(deposito, '2024-12-31').realizedSub, 2_000_000);
assert.equal(scheduleForecast(spaar, '2024-12-31').realizedSub, 0);
assert.equal(listInterest().find(r => r.id === eventId).scheduleId, deposito.id);
});
const tweeInHetVerleden = async () => {
await createSchedule(afspraak({ creditCadence: 'monthly', creditDayOfMonth: 1, startsOn: '2020-01-01', endsOn: '2020-12-31', note: 'deposito' }));
await createSchedule(afspraak({ creditCadence: 'annual', startsOn: '2020-03-01', endsOn: '2020-12-31', note: 'spaar' }));
const lijst = listSchedulesForAccount(ctx.accountId);
return { deposito: lijst.find(s => s.note === 'deposito'), spaar: lijst.find(s => s.note === 'spaar') };
};
const dueVan = (s) => dueCreditsForAccount(ctx.accountId).filter(d => d.scheduleId === s.id);
it('laat een geboekte bijschrijving van de ene afspraak de andere niet van Te boeken halen', async () => {
const { deposito, spaar } = await tweeInHetVerleden();
const julDue = dueVan(deposito).find(d => d.periodEnd === '2020-07-01');
for (const d of dueVan(deposito).filter(d => d.periodEnd < '2020-07-01')) await dismissDueCredit(d);
await bookDueCredit(julDue, julDue.projected_sub);
assert.deepEqual(dueVan(spaar).map(d => d.periodEnd), ['2020-12-31'], 'de spaarrekening houdt haar bijschrijving');
assert.ok(!dueVan(deposito).some(d => d.periodEnd === '2020-07-01'), 'de geboekte periode is weg');
});
it('laat een losse rente-boeking alleen de afspraak raken waaronder hij valt', async () => {
const { deposito, spaar } = await tweeInHetVerleden();
renteEvent('2020-06-15', 1_000);
assert.deepEqual(dueVan(spaar), [], 'de import vult de spaarperiode');
assert.ok(dueVan(deposito).some(d => d.periodEnd === '2020-07-01'), 'en niet de periode van het deposito');
});
});
describe('createSchedule: validatie', () => {
it('weigert een einddatum vóór de startdatum', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'term_end', startsOn: '2024-06-01', endsOn: '2024-01-01',
})), (e) => e.key === 'accounts.schEndBeforeStart');
});
it('eist een weekdag bij een wekelijkse cadans', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'weekly', startsOn: '2024-01-01', endsOn: '2024-12-31',
})), (e) => e.key === 'interest.weekdayRequired');
});
it('staat geen dag-van-de-maand toe bij een wekelijkse cadans', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'weekly', creditWeekday: 1, creditDayOfMonth: 15,
startsOn: '2024-01-01', endsOn: '2024-12-31',
})), /has no day of the month/);
});
it('laat een vaste afspraak zónder einddatum toe (doorlopend)', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null,
}));
assert.ok(id > 0);
});
it('laat een vaste afspraak zónder hoofdsom toe (volgt het saldo)', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: null,
}));
assert.ok(id > 0);
});
it('weigert een hoofdsom van nul, want dat is een vergissing en geen keuze', async () => {
await assert.rejects(() => createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: 0,
})), (e) => e.key === 'interest.principalPositive');
});
it('weigert hoofdsom of cadans op een variabele afspraak', async () => {
await assert.rejects(() => createSchedule({
...afspraak({ creditCadence: null, startsOn: '2024-01-01', endsOn: '2024-12-31' }),
rateKind: 'variable',
}), /manual or import/);
});
});
describe('due-bijschrijvingen van nul', () => {
it('biedt een periode zonder bedrag niet aan', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0,
startsOn: '2020-01-01', endsOn: '2022-01-01',
}));
assert.deepEqual(dueCreditsForAccount(ctx.accountId), []);
});
it('biedt de perioden mét een bedrag nog steeds aan', async () => {
await createSchedule(afspraak({
creditCadence: 'annual', annualRate: 0.05,
startsOn: '2020-01-01', endsOn: '2022-01-01',
}));
const dues = dueCreditsForAccount(ctx.accountId);
assert.equal(dues.length, 2);
assert.ok(dues.every(d => d.projected_sub > 0));
});
});
describe('renteperioden: een rente die per datum wijzigt', () => {
async function jaarAfspraak(extra) {
return createSchedule(afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2024-12-31', ...extra,
}));
}
const dueBedrag = () => dueCreditsForAccount(ctx.accountId)[0].projected_sub;
it('rekent zonder perioden precies zoals voorheen', async () => {
await jaarAfspraak();
assert.equal(dueBedrag(), 5_000_000);
});
it('knipt de bijschrijving op de ingangsdatum van een wijziging', async () => {
const id = await jaarAfspraak();
await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.01 });
assert.equal(dueBedrag(), 2_994_521);
});
it('telt twee wijzigingen in één periode allebei mee', async () => {
const id = await jaarAfspraak();
await createPeriod(id, { startsOn: '2024-04-01', annualRate: 0.04 });
await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.03 });
const verwacht = Math.round(100_000_000 * (0.05 * 91 / 365 + 0.04 * 91 / 365 + 0.03 * 183 / 365));
assert.equal(dueBedrag(), verwacht);
assert.notEqual(dueBedrag(), Math.round(100_000_000 * (0.05 * 182 / 365 + 0.03 * 183 / 365)),
'de middelste periode mag niet overgeslagen worden');
});
it('gebruikt de laatste wijziging vóór het venster als beginrente', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: '2024-12-31',
}));
await createPeriod(id, { startsOn: '2024-02-01', annualRate: 0.01 });
const derde = dueCreditsForAccount(ctx.accountId).find(d => d.periodEnd === '2024-10-01');
assert.equal(derde.projected_sub, Math.round(100_000_000 * 0.01 * 92 / 365));
});
it('telt in de prognose elk tarief over zijn eigen dagen', async () => {
const id = await createSchedule(afspraak({
creditCadence: 'term_end', startsOn: '2024-01-01', endsOn: '2025-12-31',
}));
await createPeriod(id, { startsOn: '2024-12-31', annualRate: 0.03 });
const s = listSchedulesForAccount(ctx.accountId)[0];
assert.equal(scheduleForecast(s, '2024-01-01').termTotalSub, 8_000_000);
});
it('weigert een ingangsdatum op of vóór de start van de afspraak', async () => {
const id = await jaarAfspraak();
await assert.rejects(() => createPeriod(id, { startsOn: '2024-01-01', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartBeforeSchedule');
await assert.rejects(() => createPeriod(id, { startsOn: '2023-12-31', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartBeforeSchedule');
});
it('weigert een ingangsdatum ná het einde van de afspraak', async () => {
const id = await jaarAfspraak();
await assert.rejects(() => createPeriod(id, { startsOn: '2025-06-01', annualRate: 0.01 }), (e) => e.key === 'interest.periodStartAfterEnd');
});
it('weigert een periode op een variabele afspraak', async () => {
const id = await createSchedule({
accountId: ctx.accountId, rateKind: 'variable', payoutAssetId: ctx.assetId,
principalAssetId: null, principalAmount: null, annualRate: null,
startsOn: '2024-01-01', endsOn: null, creditCadence: null, creditWeekday: null, creditDayOfMonth: null,
});
await assert.rejects(() => createPeriod(id, { startsOn: '2024-06-01', annualRate: 0.01 }), /manual or import/);
});
it('laat een periode bijwerken en verwijderen, en dat verandert het bedrag terug', async () => {
const id = await jaarAfspraak();
const pid = await createPeriod(id, { startsOn: '2024-07-01', annualRate: 0.01 });
assert.equal(dueBedrag(), 2_994_521);
await updatePeriod(pid, { startsOn: '2024-07-01', annualRate: 0.05 });
assert.equal(dueBedrag(), 5_000_000, 'zelfde rente als de afspraak: hetzelfde bedrag als zonder periode');
await deletePeriod(pid);
assert.deepEqual(listPeriods(id), []);
assert.equal(dueBedrag(), 5_000_000);
});
});
describe('hoofdsom leeg: de rente volgt het saldo van de rekening', () => {
const dueVoor = (eind) => dueCreditsForAccount(ctx.accountId).find(d => d.periodEnd === eind);
function balansOp(dagIso, balanceSub) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[ctx.assetId, dagIso, ctx.accountId, balanceSub, toUtcIso(`${dagIso}T00:00:00`)]);
}
function storting(dagIso, bedragSub) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', ?, ?, 1, ?, 'manual', ?)`,
[ctx.assetId, `${dagIso}T00:00:00`, bedragSub, ctx.accountId, `${dagIso}T00:00:00`]);
}
async function saldoAfspraak() {
return createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-01-01', endsOn: null, principalAmount: null,
}));
}
it('rekent over de stand en niet over een vast bedrag', async () => {
storting('2024-01-01', EEN_BTC);
await saldoAfspraak();
assert.equal(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365));
});
it('laat een storting halverwege vanaf díe dag meetellen', async () => {
storting('2024-01-01', EEN_BTC);
storting('2024-03-01', EEN_BTC);
await saldoAfspraak();
const verwacht = Math.round(EEN_BTC * 0.05 * 60 / 365 + 2 * EEN_BTC * 0.05 * 31 / 365);
assert.equal(dueVoor('2024-04-01').projected_sub, verwacht);
assert.notEqual(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365),
'de storting van maart hoort mee te tellen');
});
it('knipt óók op een balans halverwege de periode', async () => {
balansOp('2024-01-01', EEN_BTC);
balansOp('2024-03-01', EEN_BTC / 4);
await saldoAfspraak();
const verwacht = Math.round(EEN_BTC * 0.05 * 60 / 365 + (EEN_BTC / 4) * 0.05 * 31 / 365);
assert.equal(dueVoor('2024-04-01').projected_sub, verwacht);
assert.notEqual(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365),
'de balans van maart hoort mee te tellen');
});
it('laat een balans van middernacht niet op de dag ervóór meetellen', async () => {
balansOp('2024-09-02', 12_000_000);
balansOp('2024-09-03', 14_150_000);
await createSchedule(afspraak({
creditCadence: 'quarterly', startsOn: '2024-09-02', endsOn: null, principalAmount: null,
}));
const verwacht = Math.round(12_000_000 * 0.05 * 1 / 365 + 14_150_000 * 0.05 * 28 / 365);
assert.equal(dueVoor('2024-10-01').projected_sub, verwacht);
assert.notEqual(dueVoor('2024-10-01').projected_sub, Math.round(14_150_000 * 0.05 * 29 / 365),
'de balans van 3 september mag niet op 2 september al gelden');
});
it('volgt ook een balans, want dat is dezelfde stand', async () => {
balansOp('2024-01-01', EEN_BTC);
await saldoAfspraak();
assert.equal(dueVoor('2024-04-01').projected_sub, Math.round(EEN_BTC * 0.05 * 91 / 365));
});
it('combineert een saldowijziging met een rentewijziging', async () => {
storting('2024-01-01', EEN_BTC);
storting('2024-03-01', EEN_BTC);
const id = await saldoAfspraak();
await createPeriod(id, { startsOn: '2024-02-01', annualRate: 0.02 });
const verwacht = Math.round(
EEN_BTC * 0.05 * 31 / 365 + EEN_BTC * 0.02 * 29 / 365 + 2 * EEN_BTC * 0.02 * 31 / 365);
assert.equal(dueVoor('2024-04-01').projected_sub, verwacht);
});
it('geeft geen prognose, want er is geen termijn om naar toe te rekenen', async () => {
storting('2024-01-01', EEN_BTC);
await saldoAfspraak();
const s = listSchedulesForAccount(ctx.accountId)[0];
const f = scheduleForecast(s, '2024-06-01');
assert.equal(f.termTotalSub, null, 'zonder hoofdsom én einddatum bestaat een termijntotaal niet');
assert.equal(f.remainingSub, null);
assert.equal(typeof f.realizedSub, 'number', 'wat er ontvangen is, is er wél');
});
});
+152
View File
@@ -0,0 +1,152 @@
// Generated, do not edit. sha256:d777a6778dbae1d1860839c9462d7305d69ddab9f3ae6a43a005933e2e4c226c
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { createLabel, deleteLabel, listLabels, labelUsage } = await import('../../server/data/labels.js');
beforeEach(() => { harness.recreate(); });
function account(naam = 'Ledger') {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,'wallet',1,1,1,NULL)`, [naam, toSearchName(naam)]);
return harness.exports.lastId();
}
function beweging({ labelId = null, txid = 'tx1' } = {}) {
harness.exports.run(
`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, labelId, createdAt)
VALUES (?, 'onchain', ?, 'in', 100000, ?, '2024-01-01T00:00:00')`,
[account(`acc-${txid}`), txid, labelId]);
return harness.exports.lastId();
}
describe('labels: CRUD', () => {
it('createLabel geeft het nieuwe id terug', async () => {
const id = await createLabel({ name: 'Sparen' });
assert.equal(typeof id, 'number');
assert.deepEqual(listLabels().map(l => l.name), ['Sparen']);
});
it('listLabels sorteert op naam, niet op invoervolgorde', async () => {
await createLabel({ name: 'Zakgeld' });
await createLabel({ name: 'Aflossing' });
await createLabel({ name: 'Middelen' });
assert.deepEqual(listLabels().map(l => l.name), ['Aflossing', 'Middelen', 'Zakgeld']);
});
it('deleteLabel verwijdert een ongebruikt label', async () => {
const id = await createLabel({ name: 'Tijdelijk' });
await deleteLabel(id);
assert.deepEqual(listLabels(), []);
});
});
describe('labels: deleteLabel blokkeert bij gebruik', () => {
it('weigert een label dat een beweging draagt, en noemt het aantal', async () => {
const id = await createLabel({ name: 'In gebruik' });
beweging({ labelId: id, txid: 'tx-a' });
beweging({ labelId: id, txid: 'tx-b' });
await assert.rejects(() => deleteLabel(id), (e) => e.key === 'labels.inUse' && e.params.n === 2);
});
it('laat het label én de koppeling intact na een geweigerde delete', async () => {
const id = await createLabel({ name: 'Blijft' });
const txId = beweging({ labelId: id, txid: 'tx-c' });
await assert.rejects(() => deleteLabel(id));
assert.deepEqual(listLabels().map(l => l.name), ['Blijft']);
const tx = harness.exports.one(`SELECT labelId FROM WalletTransaction WHERE id = ?`, [txId]);
assert.equal(tx.labelId, id);
});
it('laat een label wél weg als een ánder label in gebruik is', async () => {
const bezet = await createLabel({ name: 'Bezet' });
const vrij = await createLabel({ name: 'Vrij' });
beweging({ labelId: bezet, txid: 'tx-d' });
await deleteLabel(vrij);
assert.deepEqual(listLabels().map(l => l.name), ['Bezet']);
});
it('negeert bewegingen zonder label', async () => {
const id = await createLabel({ name: 'Los' });
beweging({ labelId: null, txid: 'tx-e' });
await deleteLabel(id);
assert.deepEqual(listLabels(), []);
});
});
describe('labelUsage: de telling die een handle betekenis geeft', () => {
it('geeft een rij per label, ook voor een label dat nergens gebruikt wordt', async () => {
const gebruikt = await createLabel({ name: 'Werk' });
const ongebruikt = await createLabel({ name: 'Ligt stil' });
beweging({ labelId: gebruikt, txid: 'tx-a' });
const rijen = labelUsage();
assert.equal(rijen.length, 2, 'een label zonder bewegingen hoort er ook in te staan');
const perId = new Map(rijen.map(r => [r.labelId, r.walletTransactions]));
assert.equal(perId.get(gebruikt), 1);
assert.equal(perId.get(ongebruikt), 0);
});
it('telt alleen de bewegingen van dat label', async () => {
const a = await createLabel({ name: 'Aaa' });
const b = await createLabel({ name: 'Bbb' });
beweging({ labelId: a, txid: 'tx-1' });
beweging({ labelId: a, txid: 'tx-2' });
beweging({ labelId: b, txid: 'tx-3' });
beweging({ labelId: null, txid: 'tx-4' });
const perId = new Map(labelUsage().map(r => [r.labelId, r.walletTransactions]));
assert.equal(perId.get(a), 2);
assert.equal(perId.get(b), 1);
});
it('geeft een lege lijst als er geen labels zijn', () => {
assert.deepEqual(labelUsage(), []);
});
it('levert gewone getallen en geen Map, want dit gaat ook over de data-API', () => {
assert.ok(Array.isArray(labelUsage()));
});
});
describe('randwaardes: namen die leeg of vreemd zijn', () => {
it('weigert een lege en een witruimte-naam met een leesbare fout', async () => {
await assert.rejects(() => createLabel({ name: '' }), /Labelnaam may not be empty/);
await assert.rejects(() => createLabel({ name: ' ' }), /Labelnaam may not be empty/);
await assert.rejects(() => createLabel({ name: null }), /Labelnaam may not be empty/);
assert.deepEqual(listLabels(), [], 'en er is niets aangemaakt');
});
it('noemt searchName niet meer, de kolom die de gebruiker niet kent', async () => {
await assert.rejects(() => createLabel({ name: ' ' }),
(e) => !/searchName/.test(e.message) && /Labelnaam/.test(e.message));
});
it('slaat de naam getrimd op, want een spatie erachter is dezelfde valkuil', async () => {
await createLabel({ name: ' Werk ' });
assert.deepEqual(listLabels().map(l => l.name), ['Werk']);
});
it('laat een naam van duizend tekens door', async () => {
const lang = 'y'.repeat(1000);
await createLabel({ name: lang });
assert.equal(listLabels()[0].name.length, 1000);
});
it('laat een emoji en een aanhalingsteken door, en dat hóórt', async () => {
await createLabel({ name: 'Werk 🏦' });
await createLabel({ name: 'Zogenaamd "spaargeld"' });
assert.deepEqual(listLabels().map(l => l.name).sort(), ['Werk 🏦', 'Zogenaamd "spaargeld"']);
});
});
+215
View File
@@ -0,0 +1,215 @@
// Generated, do not edit. sha256:38c4b07d1566cda74ef6cac4d031f49fd52004b6938fdee8f13e3e7678fc12c7
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { createNwcApiFake } from '../helpers/nwc-api.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
createNwcApiFake().registreer();
let stand = { level: 'free', enforced: true, why: 'none' };
const echt = await import('../../server/license.mjs');
mock.module('../../server/license.mjs', {
namedExports: {
...echt,
licenseState: () => stand,
capacityReached: (soort, huidig) => echt.capacityReached(soort, huidig, stand),
hasFeature: (naam) => echt.hasFeature(naam, stand),
allowedAssets: () => echt.allowedAssets(stand),
},
});
const { createAsset, updateAsset, deleteAsset, listAssets } = await import('../../server/data/assets.js');
const { createAccount, listAccounts } = await import('../../server/data/accounts.js');
const { createValuation, listValuations } = await import('../../server/data/valuations.js');
const { saveConnection, listConnections } = await import('../../server/data/connections.js');
const { setPriceSourceRank } = await import('../../server/data/price-source.js');
const { createWalletSource, updateWalletSource, listWalletSources } = await import('../../server/data/wallet-sources.js');
const EEN = {
symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat',
decimals: 8, displayUnit: 'unit', taxCategory: null,
};
const TWEE = { ...EEN, symbol: 'XAU', name: 'Goud', kind: 'metal', unit: 'g', subunit: 'mg', decimals: 3 };
beforeEach(() => {
harness.recreate();
stand = { level: 'free', enforced: true, why: 'none' };
});
describe('de gratis stand op fiatrekeningen', () => {
const bank = (name) => ({ name, kind: 'bank', isOwnCustody: false, scanEnabled: false, taxCategory: null });
it('laat de EERSTE bankrekening toe en weigert de tweede', async () => {
await createAccount(bank('ING'));
await assert.rejects(() => createAccount(bank('Rabo')), (fout) => {
assert.equal(fout.key, 'accounts.maxFreeBank');
assert.equal(fout.params.max, 1);
return true;
});
assert.equal(listAccounts().length, 1);
});
it('TELT ALLEEN kind bank, dus een exchange of wallet valt erbuiten', async () => {
await createAccount(bank('ING'));
await createAccount({ ...bank('Kraken'), kind: 'exchange' });
await createAccount({ ...bank('Ledger'), kind: 'wallet' });
assert.equal(listAccounts().length, 3);
});
it('laat de tweede bankrekening WEL toe in de betaalde stand', async () => {
await createAccount(bank('ING'));
stand = { level: 'full', enforced: true, why: 'licensed' };
await createAccount(bank('Rabo'));
assert.equal(listAccounts().length, 2);
});
});
describe('de gratis stand op assets', () => {
it('TELT DE BASISVALUTA NIET MEE, anders is de gratis stand onbruikbaar', async () => {
await createAsset({ ...EEN, symbol: 'EUR', name: 'Euro', kind: 'fiat', unit: 'EUR', subunit: 'cent', decimals: 2 });
const id = await createAsset(EEN);
assert.ok(id > 0);
assert.equal(listAssets().length, 2);
});
it('telt een munt die er alleen voor de KOERS is niet mee, en een munt in gebruik wel', async () => {
const DOLLAR = { ...EEN, symbol: 'USD', name: 'Dollar', kind: 'fiat', unit: 'USD', subunit: 'cent', decimals: 2 };
await createAsset(DOLLAR);
await createAsset(EEN);
assert.equal(listAssets().length, 2, 'bitcoin mag naast een ongebruikte dollar');
harness.recreate();
const usd = await createAsset(DOLLAR);
stand = { level: 'full', enforced: true, why: 'licensed' };
await createValuation({ assetId: usd, valuedOn: '2026-01-01', accountId: null, balance: 100, fiatRate: 0.9,
rateAt: '2026-01-01T00:00:00Z', source: 'manual' });
stand = { level: 'free', enforced: true, why: 'none' };
await assert.rejects(() => createAsset(EEN), (f) => f.key === 'assets.maxFree',
'met een balans in dollars is de dollar het ene asset van de gratis stand');
});
it('laat de EERSTE asset gewoon toe', async () => {
const id = await createAsset(EEN);
assert.ok(id > 0);
assert.equal(listAssets().length, 1);
});
it('weigert de TWEEDE, met een vertaalbare sleutel en niet met een SQL-fout', async () => {
await createAsset(EEN);
const POND = { ...EEN, symbol: 'GBP', name: 'Pond', kind: 'fiat', unit: 'GBP', subunit: 'pence', decimals: 2 };
await assert.rejects(() => createAsset(POND), (fout) => {
assert.equal(fout.key, 'assets.maxFree');
assert.equal(fout.params.max, 1);
assert.match(fout.message, /free version/i);
return true;
});
assert.equal(listAssets().length, 1, 'er mag niets half zijn aangemaakt');
});
it('ALLEEN BITCOIN: een ander asset wordt geweigerd, ook als het het eerste is', async () => {
await assert.rejects(() => createAsset(TWEE), (fout) => {
assert.equal(fout.key, 'assets.freeOnly');
assert.equal(fout.params.list, 'BTC');
return true;
});
assert.equal(listAssets().length, 0, 'er mag niets half zijn aangemaakt');
});
it('laat een munt toe, want die valt onder de telling en niet onder de lijst', async () => {
const id = await createAsset({ ...EEN, symbol: 'GBP', name: 'Pond', kind: 'fiat', unit: 'GBP', subunit: 'pence', decimals: 2 });
assert.ok(id > 0);
});
it('laat de tweede WEL toe in de betaalde stand', async () => {
await createAsset(EEN);
stand = { level: 'full', enforced: true, why: 'licensed' };
await createAsset(TWEE);
assert.equal(listAssets().length, 2);
});
it('NEEMT NIETS AF: wijzigen en verwijderen blijven werken boven de grens', async () => {
stand = { level: 'full', enforced: true, why: 'licensed' };
const a = await createAsset(EEN);
const b = await createAsset(TWEE);
stand = { level: 'free', enforced: true, why: 'none' };
await updateAsset(a, { ...EEN, name: 'Bitcoin hernoemd' });
assert.equal(listAssets().find((x) => x.id === a).name, 'Bitcoin hernoemd');
await deleteAsset(b);
assert.equal(listAssets().length, 1);
});
});
describe('de gratis stand op balansen', () => {
const balans = (assetId, dag) => ({
assetId, valuedOn: dag, accountId: null, balance: 100, fiatRate: 50000,
rateAt: `${dag}T00:00:00Z`, source: 'manual',
});
it('laat ÉÉN balans toe en weigert de tweede', async () => {
const asset = await createAsset(EEN);
await createValuation(balans(asset, '2026-01-01'));
await assert.rejects(() => createValuation(balans(asset, '2026-02-01')), (fout) => {
assert.equal(fout.key, 'valuations.maxFree');
assert.equal(fout.params.max, 1);
return true;
});
assert.equal(listValuations().length, 1);
});
it('laat de tweede WEL toe in de betaalde stand', async () => {
const asset = await createAsset(EEN);
await createValuation(balans(asset, '2026-01-01'));
stand = { level: 'full', enforced: true, why: 'licensed' };
await createValuation(balans(asset, '2026-02-01'));
assert.equal(listValuations().length, 2);
});
});
describe('de features van de gratis stand', () => {
it('weigert een lightning-verbinding en een eigen explorer', async () => {
await assert.rejects(() => saveConnection({ kind: 'lnd', name: 'Node', url: 'https://x' }),
(f) => f.key === 'connections.lightningPaid');
await assert.rejects(() => saveConnection({ kind: 'nwc', name: 'Wallet', url: 'nostr+walletconnect://x' }),
(f) => f.key === 'connections.lightningPaid');
await assert.rejects(() => saveConnection({ kind: 'btc_explorer', name: 'Eigen node', url: 'https://x' }),
(f) => f.key === 'connections.ownExplorerPaid');
});
it('LEEST verbindingen gewoon, want de grens zit op opslaan en niet op kijken', async () => {
stand = { level: 'full', enforced: true, why: 'licensed' };
await saveConnection({ kind: 'btc_explorer', name: 'Eigen node', url: 'https://x' });
stand = { level: 'free', enforced: true, why: 'none' };
assert.equal(listConnections('btc_explorer').length, 1);
});
it('laat die verbindingen WEL toe in de betaalde stand', async () => {
stand = { level: 'full', enforced: true, why: 'licensed' };
const id = await saveConnection({ kind: 'lnd', name: 'Node', url: 'https://x' });
assert.ok(id);
});
it('weigert een NWC- of LND-bron bij een account, ook via wijzigen, maar laat een bestaande staan', async () => {
const accountId = await createAccount({ name: 'Wallet', kind: 'wallet', isOwnCustody: true, scanEnabled: false, taxCategory: null });
await assert.rejects(() => createWalletSource({ accountId, kind: 'nwc', value: 'ab'.repeat(32), name: 'W', secret: 'nostr+walletconnect://x' }),
(f) => f.key === 'connections.lightningPaid');
await assert.rejects(() => createWalletSource({ accountId, kind: 'lnd', value: '', name: 'N', connectionId: 1 }),
(f) => f.key === 'connections.lightningPaid');
const adres = await createWalletSource({ accountId, kind: 'address', value: 'bc1qtest', name: 'A' });
await assert.rejects(() => updateWalletSource(adres, { kind: 'nwc', value: 'ab'.repeat(32), name: 'A', secret: 'nostr+walletconnect://x' }),
(f) => f.key === 'connections.lightningPaid');
stand = { level: 'full', enforced: true, why: 'licensed' };
const bron = await createWalletSource({ accountId, kind: 'nwc', value: 'cd'.repeat(32), name: 'W', secret: 'nostr+walletconnect://y' });
stand = { level: 'free', enforced: true, why: 'none' };
await updateWalletSource(bron, { kind: 'nwc', value: 'cd'.repeat(32), name: 'Nieuwe naam' });
assert.equal(listWalletSources(accountId).find(s => s.id === bron).name, 'Nieuwe naam');
});
it('weigert het wijzigen van de bronkeuze-matrix', async () => {
const asset = await createAsset(EEN);
await assert.rejects(() => setPriceSourceRank(asset, 'spot', 1),
(f) => f.key === 'integrations.matrixPaid');
});
});
+410
View File
@@ -0,0 +1,410 @@
// Generated, do not edit. sha256:ac2e17a56171cfd2df7cbf0535002e994a9b104a37a2b866dda8f2d218d16c98
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { AppError } from '../../shared/app-error.js';
import { CONFIG } from '../../shared/config.js';
import { isRetryLater } from '../../server/market-service.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const nep = { configured: true, register: [], series: null, spots: null, history: null, calls: [], spotCalls: [], historyCalls: [] };
mock.module('../../server/market-service.mjs', {
namedExports: {
isRetryLater,
marketConfigured: async () => nep.configured,
marketRegister: async () => (typeof nep.register === 'function' ? nep.register() : nep.register),
marketSeries: async (opts) => { nep.calls.push(opts); return nep.series(opts); },
marketHistory: async (opts) => { nep.historyCalls.push(opts); return nep.history(opts); },
marketSpots: async (keys, quote) => { nep.spotCalls.push({ keys, quote }); return nep.spots(keys, quote); },
},
});
const { intradaySeries, serviceSpot, serviceDayHistory, priceAtMoment } = await import('../../server/data/market.js');
const BTC_REG = { key: 'crypto:BTC', kind: 'crypto', symbol: 'BTC', unit: null, kinds: ['spot', 'history'], resolutions: ['minute', 'hour', 'day'] };
const XAU_REG = { key: 'metal:XAU', kind: 'metal', symbol: 'XAU', unit: 'ozt', kinds: ['spot', 'history'], resolutions: ['minute', 'day'] };
const ETH_DAG = { key: 'crypto:ETH', kind: 'crypto', symbol: 'ETH', unit: null, kinds: ['spot'], resolutions: ['day'] };
function asset(symbol, kind) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?, ?, ?, ?, ?, NULL, 8, 'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]);
return harness.exports.one(`SELECT id FROM Asset WHERE symbol = ?`, [symbol]).id;
}
const sinds = () => Date.now() - 3600_000;
beforeEach(() => {
harness.recreate();
nep.configured = true;
nep.register = [BTC_REG, XAU_REG, ETH_DAG];
nep.calls = [];
nep.spotCalls = [];
nep.historyCalls = [];
nep.history = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'eulerpool', points: [] });
nep.spots = async () => ({
spot: [{ instrument: 'crypto:BTC', price: 61000, currency: 'EUR', observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' }],
missing: ['crypto:ETH'], unreachable: [],
});
nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute', points: [[o.from + 60_000, 61000]] });
});
describe('intradaySeries: wat er naar de dienst gaat', () => {
it('vraagt het instrument op soort en symbool, in de rekenvaluta, en niets over het asset', async () => {
const id = asset('BTC', 'crypto');
const from = sinds();
const uit = await intradaySeries(id, from);
assert.equal(uit.status, 'ok');
assert.equal(nep.calls.length, 1);
const vraag = nep.calls[0];
assert.deepEqual(Object.keys(vraag).sort(), ['from', 'instrument', 'quote', 'resolution', 'to']);
assert.equal(vraag.instrument, 'crypto:BTC');
assert.equal(vraag.quote, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
assert.equal(vraag.resolution, 'minute');
assert.equal(vraag.from, from);
assert.equal(Object.values(vraag).includes(id), false);
});
it('geeft de munt van de BRON door en niet de gevraagde', async () => {
const id = asset('BTC', 'crypto');
nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'x', resolution: 'minute', points: [[o.from + 1, 66000]] });
const uit = await intradaySeries(id, sinds());
assert.equal(uit.currency, 'USD');
});
it('geeft de bron per punt door, ook na het omrekenen naar gram', async () => {
const id = asset('XAU', 'metal');
nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'goldapi', resolution: 'minute',
points: [[o.from + 1, 2488.278144, 'goldapi'], [o.from + 2, 2488.278144]] });
const uit = await intradaySeries(id, sinds());
assert.equal(uit.points[0][2], 'goldapi');
assert.equal(uit.points[1].length, 2);
assert.ok(Math.abs(uit.points[0][1] - 80) < 1e-9);
});
it('noemt een punt bij de naam die de dienst geeft, en zonder naam bij de sleutel', async () => {
const id = asset('BTC', 'crypto');
nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', sourceLabel: 'Een Nederlandse aanbieder',
resolution: 'minute', sourceLabels: { bitvavo: 'Een Nederlandse aanbieder' },
points: [[o.from + 1, 61000, 'bitvavo'], [o.from + 2, 61010, 'mempool']] });
const uit = await intradaySeries(id, sinds());
assert.deepEqual(uit.points.map(p => p[2]), ['Een Nederlandse aanbieder', 'mempool']);
assert.equal(uit.sourceLabel, 'Een Nederlandse aanbieder');
});
});
describe('priceAtMoment', () => {
const AT = Date.parse('2026-10-19T10:00:00Z');
const W = CONFIG.MARKET_SERVICE.MOMENT_WINDOW_MS;
it('vraagt de minuten rond het moment, zonder asset-id, en kiest het dichtstbijzijnde punt', async () => {
const id = asset('BTC', 'crypto');
nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute',
points: [[AT - 240_000, 60_000], [AT + 60_000, 61_000], [AT + 180_000, 62_000]] });
const uit = await priceAtMoment(id, new Date(AT).toISOString());
assert.deepEqual(uit, { status: 'ok', price: 61_000, currency: 'EUR', at: AT + 60_000, source: 'bitvavo' });
const vraag = nep.calls[0];
assert.deepEqual([vraag.instrument, vraag.resolution, vraag.from, vraag.to], ['crypto:BTC', 'minute', AT - W, AT + W]);
assert.equal(Object.values(vraag).includes(id), false);
});
it('een punt buiten het venster is geen koers van dat moment', async () => {
const id = asset('BTC', 'crypto');
nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute',
points: [[AT - W - 1, 60_000]] });
assert.deepEqual(await priceAtMoment(id, new Date(AT).toISOString()), { status: 'missing' });
});
it('een metaal per gram, en de bron van het punt als de dienst die noemt', async () => {
const id = asset('XAU', 'metal');
nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'x', resolution: 'minute',
points: [[AT, 2488.278144, 'goldapi']] });
const uit = await priceAtMoment(id, new Date(AT).toISOString());
assert.ok(Math.abs(uit.price - 80) < 1e-9);
assert.equal(uit.source, 'goldapi');
});
it('zonder token, buiten het register of zonder minuutreeks: niets gevraagd', async () => {
const btc = asset('BTC', 'crypto');
const eth = asset('ETH', 'crypto');
nep.configured = false;
assert.deepEqual(await priceAtMoment(btc, new Date(AT).toISOString()), { status: 'off' });
nep.configured = true;
assert.deepEqual(await priceAtMoment(eth, new Date(AT).toISOString()), { status: 'notCovered' });
assert.equal(nep.calls.length, 0);
});
it('onbereikbaar is een antwoord, en een moment in de toekomst een contractfout', async () => {
const id = asset('BTC', 'crypto');
nep.series = async () => { throw new AppError('marketService.unreachable', null, 'down'); };
const uit = await priceAtMoment(id, new Date(AT).toISOString());
assert.equal(uit.status, 'unavailable');
await assert.rejects(priceAtMoment(id, new Date(Date.now() + 60_000).toISOString()), /moment in the past/);
await assert.rejects(priceAtMoment(id, 'gisteren'), /moment in the past/);
});
});
describe('intradaySeries: wanneer er niets gevraagd wordt', () => {
it('zonder token: uit, zonder melding en zonder vraag aan de dienst', async () => {
const id = asset('BTC', 'crypto');
nep.configured = false;
assert.deepEqual(await intradaySeries(id, sinds()), { status: 'off' });
assert.equal(nep.calls.length, 0);
});
it('een asset dat het register niet kent: niet gedekt', async () => {
const id = asset('DOGE', 'crypto');
assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' });
assert.equal(nep.calls.length, 0);
});
it('een metaal in troy ounce: gedekt, en per gram, want zo bewaart de app metaal', async () => {
const id = asset('XAU', 'metal');
nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'goldapi', resolution: 'minute', points: [[o.from + 60_000, 2488.278144]] });
const uit = await intradaySeries(id, sinds());
assert.equal(uit.status, 'ok');
assert.equal(nep.calls[0].instrument, 'metal:XAU');
assert.ok(Math.abs(uit.points[0][1] - 80) < 1e-9, `${uit.points[0][1]} per gram`);
});
it('een eenheid die de app niet kent: niet gedekt, want een verkeerde factor is een stil verkeerde koers', async () => {
nep.register = [{ key: 'metal:XPT', kind: 'metal', symbol: 'XPT', unit: 'lb', resolutions: ['minute'] }];
assert.deepEqual(await intradaySeries(asset('XPT', 'metal'), sinds()), { status: 'notCovered' });
assert.equal(nep.calls.length, 0);
});
it('een instrument zonder minuutreeks: niet gedekt', async () => {
const id = asset('ETH', 'crypto');
assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' });
});
it('dezelfde code bij een andere soort is een ander instrument', async () => {
const id = asset('BTC', 'fiat');
assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' });
});
});
describe('intradaySeries: een onbereikbare dienst is een antwoord, geen fout', () => {
const metFout = (e) => { nep.series = async () => { throw e; }; };
it('api.monthUnreachable: onbeschikbaar en later opnieuw, met de maand', async () => {
const id = asset('BTC', 'crypto');
metFout(new AppError('marketService.monthUnreachable', { month: '2026-09' }, 'x'));
assert.deepEqual(await intradaySeries(id, sinds()),
{ status: 'unavailable', retryLater: true, key: 'marketService.monthUnreachable', params: { month: '2026-09' } });
});
it('dienst weg: onbeschikbaar en later opnieuw', async () => {
const id = asset('BTC', 'crypto');
metFout(new AppError('marketService.unreachable', { url: 'u', reason: 'ECONNREFUSED' }, 'x'));
const uit = await intradaySeries(id, sinds());
assert.equal(uit.status, 'unavailable');
assert.equal(uit.retryLater, true);
});
it('401: onbeschikbaar maar NIET later opnieuw, zodat de melding om een nieuw token vraagt', async () => {
const id = asset('BTC', 'crypto');
metFout(new AppError('marketService.unauthorized', null, 'x'));
const uit = await intradaySeries(id, sinds());
assert.equal(uit.status, 'unavailable');
assert.equal(uit.retryLater, false);
assert.equal(uit.key, 'marketService.unauthorized');
});
it('geeft de request-id van de mislukte aanroep mee, zodat de fout in beide logs te vinden is', async () => {
const id = asset('BTC', 'crypto');
const e = new AppError('marketService.rejected', { status: 404, key: 'api.noSeries', reason: 'x' }, 'x');
e.requestId = 'a1b2c3d4-0000-4000-8000-000000000001';
metFout(e);
assert.equal((await intradaySeries(id, sinds())).requestId, 'a1b2c3d4-0000-4000-8000-000000000001');
});
it('een fout uit het register telt net zo', async () => {
const id = asset('BTC', 'crypto');
nep.register = () => { throw new AppError('marketService.timeout', { s: 10 }, 'x'); };
const uit = await intradaySeries(id, sinds());
assert.equal(uit.status, 'unavailable');
assert.equal(uit.retryLater, true);
assert.equal(nep.calls.length, 0);
});
it('een andere fout gaat gewoon door, want dat is een fout in de app', async () => {
const id = asset('BTC', 'crypto');
metFout(new TypeError('stuk'));
await assert.rejects(() => intradaySeries(id, sinds()), /stuk/);
});
});
describe('intradaySeries: contractfouten van de app zelf', () => {
it('weigert een onbekend asset', async () => {
await assert.rejects(() => intradaySeries(9999, sinds()), /Unknown asset/);
});
it('weigert een begin in de toekomst, geen getal, of verder terug dan de grens', async () => {
const id = asset('BTC', 'crypto');
const grens = CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS * 86_400_000;
for (const slecht of [Date.now() + 60_000, 'gisteren', NaN, Date.now() - grens - 60_000]) {
await assert.rejects(() => intradaySeries(id, slecht), /sinceMs/, `sinceMs ${slecht}`);
}
assert.equal(nep.calls.length, 0);
});
});
describe('serviceSpot: de actuele koers uit de dienst (fase 3)', () => {
it('vraagt het HELE bruikbare register als pakket, in de rekenvaluta, en geeft de koers in de munt van de bron', async () => {
const id = asset('BTC', 'crypto');
const uit = await serviceSpot(id);
assert.deepEqual(uit, { status: 'ok', instrument: 'crypto:BTC', price: 61000, currency: 'EUR', observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' });
assert.equal(nep.spotCalls.length, 1);
assert.deepEqual([...nep.spotCalls[0].keys].sort(), ['crypto:BTC', 'crypto:ETH', 'metal:XAU']);
assert.equal(nep.spotCalls[0].quote, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
});
it('zonder token: uit, en geen vraag', async () => {
const id = asset('BTC', 'crypto');
nep.configured = false;
assert.deepEqual(await serviceSpot(id), { status: 'off' });
assert.equal(nep.spotCalls.length, 0);
});
it('een handmatige koers van vandaag gaat voor: de dienst wordt niet gevraagd', async () => {
const id = asset('BTC', 'crypto');
harness.exports.run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES ('manual','Handmatig','handmatig','','none',0,1,1,1,'2026-01-01')`);
const manual = harness.exports.one(`SELECT id FROM Provider WHERE key = 'manual'`).id;
const gisteren = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
harness.exports.run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 50000, 'day', ?)`,
[id, manual, `${gisteren}T00:00:00Z`]);
assert.equal((await serviceSpot(id)).status, 'ok');
const vandaag = new Date().toISOString().slice(0, 10);
harness.exports.run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 50000, 'day', ?)`,
[id, manual, `${vandaag}T00:00:00Z`]);
const voor = nep.spotCalls.length;
assert.deepEqual(await serviceSpot(id), { status: 'local' });
assert.equal(nep.spotCalls.length, voor);
});
it('niet in het register: niet gedekt, zonder vraag om koersen', async () => {
assert.deepEqual(await serviceSpot(asset('DOGE', 'crypto')), { status: 'notCovered' });
assert.equal(nep.spotCalls.length, 0);
});
it('een metaal per troy ounce komt per gram terug, in de munt van de bron', async () => {
nep.spots = async () => ({ spot: [{ instrument: 'metal:XAU', price: 2488.278144, currency: 'USD', observedAt: 1, source: 'goldapi', sourceLabel: 'GoldAPI' }], missing: [], unreachable: [] });
const uit = await serviceSpot(asset('XAU', 'metal'));
assert.equal(uit.status, 'ok');
assert.equal(uit.currency, 'USD');
assert.ok(Math.abs(uit.price - 80) < 1e-9, `${uit.price} per gram`);
});
it('`missing` is een gat, `unreachable` is later opnieuw', async () => {
assert.deepEqual(await serviceSpot(asset('ETH', 'crypto')), { status: 'missing' });
nep.spots = async () => ({ spot: [], missing: [], unreachable: ['crypto:BTC'] });
assert.deepEqual(await serviceSpot(asset('BTC', 'crypto')),
{ status: 'unavailable', retryLater: true, key: 'marketService.spotUnreachable', params: null });
});
it('dienst weg of token geweigerd: een antwoord, met het oordeel over later opnieuw', async () => {
const id = asset('BTC', 'crypto');
nep.spots = async () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'ECONNREFUSED' }, 'x'); };
assert.equal((await serviceSpot(id)).retryLater, true);
nep.spots = async () => { throw new AppError('marketService.unauthorized', null, 'x'); };
const uit = await serviceSpot(id);
assert.equal(uit.status, 'unavailable');
assert.equal(uit.retryLater, false);
});
it('weigert een onbekend asset', async () => {
await assert.rejects(() => serviceSpot(9999), /Unknown asset/);
});
});
describe('serviceCoverage: welke assets de dienst dekt', () => {
it('geeft de assets waarvan het register het instrument kent, metaal in troy ounce inbegrepen', async () => {
const { serviceCoverage } = await import('../../server/data/market.js');
const b = asset('BTC', 'crypto'), e = asset('ETH', 'crypto');
const x = asset('XAU', 'metal');
asset('DOGE', 'crypto');
assert.deepEqual(await serviceCoverage(), { status: 'ok', assetIds: [b, e, x] });
});
it('zonder token: uit, en het register wordt niet gevraagd', async () => {
const { serviceCoverage } = await import('../../server/data/market.js');
nep.configured = false;
let gevraagd = false;
nep.register = () => { gevraagd = true; return []; };
assert.deepEqual(await serviceCoverage(), { status: 'off' });
assert.equal(gevraagd, false);
});
it('register onbereikbaar: onbeschikbaar, geen fout', async () => {
const { serviceCoverage } = await import('../../server/data/market.js');
nep.register = () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'x' }, 'x'); };
assert.equal((await serviceCoverage()).status, 'unavailable');
});
});
describe('serviceDayHistory: dagkoersen uit de dienst (10-10-2026)', () => {
const DAG = 86_400_000;
const vandaag = () => Math.floor(Date.now() / DAG) * DAG;
const sinds = () => vandaag() - 10 * DAG;
it('geeft alleen afgesloten dagen, geen momenten en niet vandaag, in de munt van de bron', async () => {
const id = asset('BTC', 'crypto');
nep.history = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'eulerpool', points: [
[vandaag() - 2 * DAG, 60000, 'day'], [vandaag() - 2 * DAG + 3600_000, 60500, 'moment'],
[vandaag() - DAG, 61000, 'day'], [vandaag(), 62000, 'day'],
] });
const uit = await serviceDayHistory(id, sinds());
assert.deepEqual(uit, { status: 'ok', instrument: 'crypto:BTC', currency: 'USD', source: 'eulerpool',
days: [[vandaag() - 2 * DAG, 60000], [vandaag() - DAG, 61000]] });
assert.deepEqual(Object.keys(nep.historyCalls[0]).sort(), ['from', 'instrument', 'quote', 'to']);
assert.equal(Object.values(nep.historyCalls[0]).includes(id), false, 'een asset-id ging de deur uit');
});
it('houdt per dag de bron die de dienst noemde, bij zijn naam (de alias)', async () => {
nep.history = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo',
sourceLabels: { bitvavo: 'Bitvavo (beurs)' }, points: [
[vandaag() - 2 * DAG, 60000, 'day', 'bitvavo'],
[vandaag() - DAG, 61000, 'day', 'onbekend'],
[vandaag() - DAG + 1, 61500, 'day'],
] });
const id = asset('BTC', 'crypto');
const uit = await serviceDayHistory(id, sinds());
assert.equal(uit.days[0][2], 'Bitvavo (beurs)', 'de alias uit sourceLabels');
assert.equal(uit.days[1][2], 'onbekend', 'zonder naam de sleutel');
assert.equal(uit.days[2].length, 2, 'een rij zonder bron krijgt er geen');
});
it('metaal per gram', async () => {
nep.history = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'eulerpool', points: [[vandaag() - DAG, 2488.278144, 'day']] });
const uit = await serviceDayHistory(asset('XAU', 'metal'), sinds());
assert.ok(Math.abs(uit.days[0][1] - 80) < 1e-9);
});
it('een instrument zonder historie in het register wordt niet gevraagd', async () => {
assert.deepEqual(await serviceDayHistory(asset('ETH', 'crypto'), sinds()), { status: 'notCovered' });
assert.equal(nep.historyCalls.length, 0);
});
it('zonder token uit, en een onbereikbare dienst is een antwoord', async () => {
const id = asset('BTC', 'crypto');
nep.configured = false;
assert.deepEqual(await serviceDayHistory(id, sinds()), { status: 'off' });
nep.configured = true;
nep.history = async () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'x' }, 'x'); };
assert.equal((await serviceDayHistory(id, sinds())).retryLater, true);
});
it('weigert een begin in de toekomst of verder terug dan de grens', async () => {
const id = asset('BTC', 'crypto');
for (const slecht of [Date.now() + DAG, 'gisteren', Date.now() - (CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS + 2) * DAG]) {
await assert.rejects(() => serviceDayHistory(id, slecht), /sinceMs/);
}
});
});
+302
View File
@@ -0,0 +1,302 @@
// Generated, do not edit. sha256:ba6482c6f8221f445c935cfd65e76c09e943da662ff5dab04e50d8a76e004316
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { mergeAccounts, mergeCheck } = await import('../../server/data/accounts-merge.js');
const { eventsNetByAccount } = await import('../../server/data/events.js');
let btc;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
btc = harness.exports.lastId();
});
function account(naam, kind = 'wallet') {
const eigen = ACCOUNT_SOURCE_KINDS[kind].length > 0 ? 1 : 0;
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), kind, eigen, 0]);
return harness.exports.lastId();
}
function event({ type = 'buy', from = null, to = null, amount = 1000, at = '2024-01-01T00:00:00', unitFee = null }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,'manual',?)`,
[btc, type, at, amount, type === 'buy' || type === 'sell' ? 1 : null, unitFee, from, to, at]);
return harness.exports.lastId();
}
function waardering({ acc, onDate, balance, rateAt = null }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?, 'manual')`,
[btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]);
return harness.exports.lastId();
}
const tel = (tabel, kolom, id) =>
harness.exports.one(`SELECT COUNT(*) AS n FROM ${tabel} WHERE ${kolom} = ?`, [id]).n;
const bestaatAccount = id => harness.exports.one(`SELECT 1 AS x FROM Account WHERE id=?`, [id]) != null;
describe('mergeCheck: compatibiliteit', () => {
it('vraagt om twee accounts', () => {
const a = account('A');
assert.equal(mergeCheck(a, null).reason, 'pickTwo');
assert.equal(mergeCheck(null, a).reason, 'pickTwo');
assert.equal(mergeCheck(a, null).counts, null);
});
it('weigert een account met zichzelf', () => {
const a = account('A');
assert.equal(mergeCheck(a, a).reason, 'sameAccount');
assert.equal(mergeCheck(String(a), a).reason, 'sameAccount',
'ook als het id als string binnenkomt (uit een <select>)');
});
it('weigert een onbekend account', () => {
const a = account('A');
assert.equal(mergeCheck(a, 9999).reason, 'notFound');
});
it('weigert custodial × trackable', () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
assert.equal(mergeCheck(beurs, wallet).reason, 'sourceMismatch');
assert.equal(mergeCheck(wallet, beurs).reason, 'sourceMismatch',
'de regel geldt in beide richtingen');
});
it('weigert een overlever die de bron-soorten van de opheffer niet kan dragen', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(wallet, node).reason, 'kindCannotCarry');
});
it('staat de andere richting wél toe', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(node, wallet).ok, true,
'lightning kan alles dragen wat wallet kan');
});
it('staat twee custodial soorten toe', () => {
const bank = account('Bank', 'bank');
const kluis = account('Kluis', 'vault');
assert.equal(mergeCheck(bank, kluis).ok, true);
});
it('telt wat er zou verhuizen, en kijkt naar beide event-kanten', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
event({ type: 'buy', to: s });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qtest', '2024-01-01T00:00:00')`, [v]);
waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
const chk = mergeCheck(s, v);
assert.equal(chk.ok, true);
assert.deepEqual(chk.counts, { sources: 1, events: 2, schedules: 0, valuations: 1 });
assert.equal(chk.survivorName, 'S');
assert.equal(chk.victimName, 'V');
});
it('verandert niets aan de database', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
mergeCheck(s, v);
assert.equal(tel('Event', 'toAccountId', v), 1, 'een preview hoort niets te muteren');
assert.ok(bestaatAccount(v));
});
});
describe('mergeAccounts: verhuizing van kinddata', () => {
it('weigert een incompatibele merge en laat alles staan', async () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
event({ type: 'buy', to: wallet });
await assert.rejects(() => mergeAccounts(beurs, wallet), (e) => e.key === 'accounts.mergeReason_sourceMismatch');
assert.ok(bestaatAccount(wallet), 'de opheffer bestaat nog');
assert.equal(tel('Event', 'toAccountId', wallet), 1);
});
it('verhuist alle acht soorten kinddata en verwijdert de opheffer', async () => {
const s = account('Overlever');
const v = account('Opheffer');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO WalletAddress (accountId, address) VALUES (?, 'bc1qadres')`, [v]);
harness.exports.run(`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, createdAt)
VALUES (?, 'onchain', 'tx1', 'in', 100, '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO ScanLog (accountId, scannedAt, status)
VALUES (?, '2024-01-01T00:00:00', 'success')`, [v]);
harness.exports.run(`INSERT INTO ReconcileProposal
(accountId, assetId, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, 100, 'in', 'transfer', 'pending', '2024-01-01T00:00:00')`, [v, btc]);
harness.exports.run(`INSERT INTO ImportBatch (accountId, filename, importedAt, rowCount)
VALUES (?, 'x.csv', '2024-01-01T00:00:00', 3)`, [v]);
harness.exports.run(`INSERT INTO InterestSchedule
(accountId, rateKind, payoutAssetId, startsOn, compounding, createdAt)
VALUES (?, 'variable', ?, '2024-01-01', 0, '2024-01-01T00:00:00')`, [v, btc]);
const counts = await mergeAccounts(s, v);
assert.equal(tel('Event', 'toAccountId', s), 1);
assert.equal(tel('Event', 'fromAccountId', s), 1);
assert.equal(tel('WalletSource', 'accountId', s), 1);
assert.equal(tel('WalletAddress', 'accountId', s), 1);
assert.equal(tel('WalletTransaction', 'accountId', s), 1);
assert.equal(tel('ScanLog', 'accountId', s), 1);
assert.equal(tel('ReconcileProposal', 'accountId', s), 1);
assert.equal(tel('ImportBatch', 'accountId', s), 1);
assert.equal(tel('InterestSchedule', 'accountId', s), 1);
for (const [tabel, kolom] of [['Event', 'toAccountId'], ['Event', 'fromAccountId'],
['WalletSource', 'accountId'], ['WalletAddress', 'accountId'], ['WalletTransaction', 'accountId'],
['ScanLog', 'accountId'], ['ReconcileProposal', 'accountId'], ['ImportBatch', 'accountId'],
['InterestSchedule', 'accountId']]) {
assert.equal(tel(tabel, kolom, v), 0, `${tabel}.${kolom} verwijst nog naar de opheffer`);
}
assert.equal(bestaatAccount(v), false, 'de opheffer hoort verdwenen te zijn');
assert.ok(bestaatAccount(s));
assert.equal(counts.events, 2, 'de teruggegeven counts komen uit de voorbeschouwing');
});
it("laat de bron-id's ongemoeid, zodat NWC-secrets geldig blijven", async () => {
const s = account('S');
const v = account('V');
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
const bronId = harness.exports.lastId();
await mergeAccounts(s, v);
const bron = harness.exports.one(`SELECT id, accountId FROM WalletSource`);
assert.equal(bron.id, bronId, 'het bron-id mag niet wijzigen');
assert.equal(bron.accountId, s);
});
});
describe('mergeAccounts: waarderingen', () => {
const verschil = (valId) => {
const v = harness.exports.one(`SELECT accountId, assetId, rateAt, balance FROM Valuation WHERE id=?`, [valId]);
return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt);
};
it('houdt het verschil van elke waardering gelijk', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
assert.equal(verschil(valV), 200);
assert.equal(verschil(valS), 0);
await mergeAccounts(s, v);
assert.equal(verschil(valV), 200, 'een bestaand verschil moet behouden blijven');
assert.equal(verschil(valS), 0, 'reconciled (0) moet 0 blijven');
});
it('corrigeert de balance daarvoor, ook van de overlever zelf', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
await mergeAccounts(s, v);
const balans = id => harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [id]).balance;
assert.equal(balans(valV), 1700, '1500 grootboek + 200 verschil');
assert.equal(balans(valS), 1500, '1500 grootboek + 0 verschil, dus herschreven van 500');
});
it('verhuist een niet-botsende waardering naar de overlever', async () => {
const s = account('S');
const v = account('V');
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 10 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT accountId FROM Valuation WHERE id=?`, [val]).accountId, s);
});
it('houdt bij een botsing precies één waardering over', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
const over = harness.exports.all(`SELECT accountId, balance FROM Valuation`);
assert.equal(over.length, 1, 'geen dubbele rij, geen UNIQUE-fout');
assert.equal(over[0].accountId, s, 'de overgebleven rij hangt aan de overlever');
});
it('wist bij een botsing de rij van de opheffer, niet die van de overlever', async () => {
const s = account('S');
const v = account('V');
const valS = waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
assert.ok(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valS]),
'de waardering van de overlever blijft');
assert.equal(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valV]), null,
'de botsende rij van de opheffer wijkt');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation`).balance, 500,
'en dus blijft de gemeten balans van het behouden account staan');
});
it('botst alleen op dezelfde datum, niet op hetzelfde asset alleen', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-02', balance: 1200 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Valuation`).n, 2);
});
it('rekent het verschil met transfers en fees mee', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'transfer', from: v, to: null, amount: 300, unitFee: 20, at: '2024-01-15T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 700 });
assert.equal(verschil(val), 20);
await mergeAccounts(s, v);
assert.equal(verschil(val), 20);
});
it('negeert waarderingen ná het rateAt-moment bij de correctie', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 9999, at: '2024-06-01T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
assert.equal(verschil(val), 0);
await mergeAccounts(s, v);
assert.equal(verschil(val), 0, 'de late aankoop hoort buiten dit venster te vallen');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [val]).balance, 1000,
'balance blijft 1000: het grootboek t/m die datum is niet veranderd');
});
});
+383
View File
@@ -0,0 +1,383 @@
// Generated, do not edit. sha256:e52c9e2ffe2a1f8ec8db420c958b634e2965734f817cfb40431e9184829c5b64
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
createMetalProduct, deleteMetalProduct, hasMetalAssets, listMetalAssets, listMetalProducts,
listMetalProductsForAsset, metalHoldings, updateMetalProduct,
} = await import('../../server/data/metals.js');
let goud, zilver;
beforeEach(() => {
harness.recreate();
goud = asset('XAU', 'Goud');
zilver = asset('XAG', 'Zilver');
});
function asset(symbol, name) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,'metal','g','g',7,'unit')`, [symbol, name, toSearchName(name)]);
return harness.exports.lastId();
}
function product({ assetId = null, name = 'Krugerrand', form = 'coin', grams = 31 } = {}) {
harness.exports.run(
`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?,?,?,?,?)`, [assetId ?? goud, name, toSearchName(name), form, grams]);
return harness.exports.lastId();
}
function event({ productId = null, type = 'buy', count = 1, fee = null, at = '2024-03-01T00:00:00',
assetId = null, amount = 310000000, fiatPrice = 2000, priceOrigin = null } = {}) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, productId, productCount,
priceOrigin, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,?, 'manual', ?)`,
[assetId ?? goud, type, at, amount, fiatPrice, fee, productId, productId === null ? null : count,
priceOrigin, at]);
return harness.exports.lastId();
}
const bezit = () => metalHoldings();
const regelVan = id => bezit().find(r => r.id === id) ?? null;
describe('metalHoldings: netto aantal', () => {
it('geeft niets terug zonder producten', () => {
assert.deepEqual(bezit(), []);
});
it('laat een product zonder events weg', () => {
product();
assert.deepEqual(bezit(), [], 'net_count is 0, dus HAVING sluit hem uit');
});
it('telt aankopen op en verkopen af', () => {
const p = product();
event({ productId: p, type: 'buy', count: 5 });
event({ productId: p, type: 'buy', count: 3 });
event({ productId: p, type: 'sell', count: 2 });
assert.equal(regelVan(p).net_count, 6);
});
it('laat een volledig verkochte positie verdwijnen', () => {
const p = product();
event({ productId: p, type: 'buy', count: 2 });
event({ productId: p, type: 'sell', count: 2 });
assert.equal(regelVan(p), null);
});
it('laat een negatief saldo ook weg', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1 });
event({ productId: p, type: 'sell', count: 3 });
assert.equal(regelVan(p), null);
});
it('negeert transfers en rente in het netto aantal', () => {
const p = product();
event({ productId: p, type: 'buy', count: 4 });
event({ productId: p, type: 'transfer', count: 4 });
assert.equal(regelVan(p).net_count, 4);
});
it('houdt producten gescheiden', () => {
const munt = product({ name: 'Krugerrand' });
const baar = product({ name: 'Baar 100g', form: 'bar', grams: 100 });
event({ productId: munt, count: 2 });
event({ productId: baar, count: 1 });
assert.equal(regelVan(munt).net_count, 2);
assert.equal(regelVan(baar).net_count, 1);
});
it('negeert events zonder product', () => {
const p = product();
event({ productId: p, count: 2 });
event({ productId: null, count: 99 });
assert.equal(regelVan(p).net_count, 2);
});
});
describe('metalHoldings: events zonder stuksaantal', () => {
it('kán geen event op een product zonder productCount meer maken', () => {
const p = product();
assert.throws(() => event({ productId: p, type: 'buy', count: null, fee: 500 }),
/CHECK constraint failed/);
});
it('telt een event mét stuks gewoon mee', () => {
const p = product();
event({ productId: p, type: 'buy', count: 3, fee: 60 });
const r = regelVan(p);
assert.equal(r.net_count, 3);
assert.equal(r.buy_events, 1);
assert.equal(r.buy_premium, 60);
});
it('laat een product zonder events weg', () => {
const p = product();
assert.equal(regelVan(p), null, 'net_count blijft 0 door de join-voorwaarde');
});
});
describe('metalHoldings: premie en aankoop-tellers', () => {
it('somt fiatFee over de aankopen', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, fee: 40 });
event({ productId: p, type: 'buy', count: 2, fee: 75 });
const r = regelVan(p);
assert.equal(r.buy_premium, 115);
assert.equal(r.buy_events, 2, 'per event, niet per stuk: zo rekent de pagina het gemiddelde');
assert.equal(r.buy_count, 3, 'buy_count telt wél de stuks');
});
it('rekent een ontbrekende fee als 0 in plaats van null', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, fee: null });
event({ productId: p, type: 'buy', count: 1, fee: 50 });
const r = regelVan(p);
assert.equal(r.buy_premium, 50);
assert.equal(r.buy_events, 2, 'het event telt wél mee als aankoop');
});
it('laat een verkoop de premie en de aankoop-tellers ongemoeid', () => {
const p = product();
event({ productId: p, type: 'buy', count: 3, fee: 90 });
event({ productId: p, type: 'sell', count: 1, fee: 25 });
const r = regelVan(p);
assert.equal(r.net_count, 2, 'het bezit daalt wel');
assert.equal(r.buy_premium, 90, 'de verkoopkosten horen niet bij de aankooppremie');
assert.equal(r.buy_count, 3);
assert.equal(r.buy_events, 1);
});
it('geeft 0 in plaats van null als er geen fees zijn', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, fee: null });
assert.equal(regelVan(p).buy_premium, 0);
});
});
describe('metalHoldings: de kostprijs per product', () => {
it('somt het GEWICHT en de bestede spot over de aankopen', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 2000 });
event({ productId: p, type: 'buy', count: 2, amount: 620000000, fiatPrice: 2500 });
const r = regelVan(p);
assert.equal(r.buy_amount, 930000000, 'amount en niet productCount: dit is gewicht, geen stuks');
assert.equal(r.buy_spot_raw, 310000000 * 2000 + 620000000 * 2500);
assert.equal(r.buy_spot_raw / r.buy_amount, (310000000 * 2000 + 620000000 * 2500) / 930000000);
});
it('laat een verkoop de kostprijs ongemoeid', () => {
const p = product();
event({ productId: p, type: 'buy', count: 3, amount: 930000000, fiatPrice: 2000 });
event({ productId: p, type: 'sell', count: 1, amount: 310000000, fiatPrice: 3000 });
const r = regelVan(p);
assert.equal(r.net_count, 2, 'het bezit daalt wel');
assert.equal(r.buy_amount, 930000000);
assert.equal(r.buy_spot_raw, 930000000 * 2000);
});
it('geeft 0 en geen null als er niets gekocht is', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1 });
const r = regelVan(p);
assert.equal(typeof r.buy_amount, 'number');
assert.equal(typeof r.buy_spot_raw, 'number');
});
it('laat een gekregen munt buiten de kostprijs en telt hem wel in het bezit', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 2000, fee: 40 });
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 0, priceOrigin: 'none' });
const r = regelVan(p);
assert.equal(r.net_count, 2, 'twee munten in bezit');
assert.equal(r.buy_amount, 310000000, 'maar één telt voor de kostprijs');
assert.equal(r.buy_spot_raw, 310000000 * 2000);
assert.equal(r.buy_events, 1);
assert.equal(r.buy_premium, 40, 'aan een gift is geen premie betaald');
assert.equal(r.gift_amount, 310000000, 'en het gewicht ervan gaat mee, voor de kostprijs nul');
});
it('houdt een munt met ONBEKENDE herkomst in de kostprijs', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 2000 });
const r = regelVan(p);
assert.equal(r.buy_amount, 310000000);
assert.equal(r.gift_amount, 0);
});
});
describe('metalHoldings: resultaatvorm', () => {
it('verrijkt met de asset-gegevens', () => {
const p = product({ name: 'Maple Leaf', form: 'coin', grams: 31 });
event({ productId: p, count: 1 });
const r = regelVan(p);
assert.equal(r.assetId, goud);
assert.equal(r.asset_symbol, 'XAU');
assert.equal(r.asset_name, 'Goud');
assert.equal(r.name, 'Maple Leaf');
assert.equal(r.form, 'coin');
assert.equal(r.fineGrams, 31, 'gewicht komt uit het product; de waarde rekent de pagina met de spot');
});
it('sorteert op asset, dan vorm, dan gewicht', () => {
const zilverBaar = product({ assetId: zilver, name: 'Zilverbaar', form: 'bar', grams: 1000 });
const goudBaarKlein = product({ name: 'Goudbaar 10g', form: 'bar', grams: 10 });
const goudBaarGroot = product({ name: 'Goudbaar 100g', form: 'bar', grams: 100 });
const goudMunt = product({ name: 'Krugerrand', form: 'coin', grams: 31 });
for (const p of [zilverBaar, goudBaarKlein, goudBaarGroot, goudMunt]) {
event({ productId: p, count: 1, assetId: p === zilverBaar ? zilver : goud });
}
assert.deepEqual(bezit().map(r => r.id), [zilverBaar, goudBaarKlein, goudBaarGroot, goudMunt]);
});
});
describe('hasMetalAssets en listMetalAssets', () => {
it('ziet metaal-assets en negeert crypto', () => {
assert.equal(hasMetalAssets(), true);
assert.deepEqual(listMetalAssets().map(a => a.symbol), ['XAG', 'XAU'], 'gesorteerd op symbool');
harness.exports.run(`DELETE FROM Asset`);
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
assert.equal(hasMetalAssets(), false);
assert.deepEqual(listMetalAssets(), []);
});
});
describe('listMetalProducts en listMetalProductsForAsset', () => {
it('geeft alle producten met asset-gegevens, gesorteerd als het bezit', () => {
const munt = product({ name: 'Krugerrand', form: 'coin', grams: 31 });
const baar = product({ name: 'Goudbaar', form: 'bar', grams: 100 });
const zilverBaar = product({ assetId: zilver, name: 'Zilverbaar', form: 'bar', grams: 1000 });
const alle = listMetalProducts();
assert.deepEqual(alle.map(p => p.id), [zilverBaar, baar, munt],
'zelfde ordening als metalHoldings: asset, vorm, gewicht');
assert.equal(alle[0].asset_symbol, 'XAG');
assert.equal(alle[0].asset_name, 'Zilver');
});
it('geeft producten van één asset zonder de asset-join', () => {
product({ name: 'Krugerrand', form: 'coin', grams: 31 });
product({ assetId: zilver, name: 'Zilverbaar', form: 'bar', grams: 1000 });
const vanGoud = listMetalProductsForAsset(goud);
assert.deepEqual(vanGoud.map(p => p.name), ['Krugerrand']);
assert.equal(vanGoud[0].asset_symbol, undefined, 'geen join: de aanroeper kent het asset al');
});
it('toont ook een product zonder enige aankoop', () => {
const p = product({ name: 'Nog nooit gekocht', form: 'coin', grams: 8 });
assert.equal(listMetalProducts().some(x => x.id === p), true);
assert.equal(bezit().some(x => x.id === p), false);
});
it('is leeg zonder producten', () => {
assert.deepEqual(listMetalProducts(), []);
assert.deepEqual(listMetalProductsForAsset(goud), []);
});
});
describe('createMetalProduct', () => {
const geldig = (extra = {}) => ({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 311035, ...extra });
it('bewaart de velden en geeft het id terug', async () => {
const id = await createMetalProduct(geldig());
const p = harness.exports.one(`SELECT * FROM MetalProduct WHERE id = ?`, [id]);
assert.equal(p.assetId, goud);
assert.equal(p.name, 'Krugerrand');
assert.equal(p.form, 'coin');
assert.equal(p.fineGrams, 311035, 'fijn gewicht in de gram-basis, niet in troy ounce');
});
it('trimt de naam', async () => {
const id = await createMetalProduct(geldig({ name: ' Maple Leaf ' }));
assert.equal(harness.exports.one(`SELECT name FROM MetalProduct WHERE id = ?`, [id]).name, 'Maple Leaf');
});
it('weigert een naam die alleen uit witruimte bestaat', async () => {
await assert.rejects(() => createMetalProduct(geldig({ name: ' ' })), (e) => e.key === 'metalProducts.nameRequired');
});
it('weigert een onbekende vorm', async () => {
await assert.rejects(() => createMetalProduct(geldig({ form: 'ketting' })), /form/);
});
it('weigert een gewicht van nul of minder, en een gebroken getal', async () => {
await assert.rejects(() => createMetalProduct(geldig({ fineGrams: 0 })), (e) => e.key === 'metalProducts.fineGramsPositive');
await assert.rejects(() => createMetalProduct(geldig({ fineGrams: -5 })), (e) => e.key === 'metalProducts.fineGramsPositive');
await assert.rejects(() => createMetalProduct(geldig({ fineGrams: 31.5 })), (e) => e.key === 'metalProducts.fineGramsPositive');
});
it('weigert een asset dat geen metaal is', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
const btc = harness.exports.lastId();
await assert.rejects(() => createMetalProduct(geldig({ assetId: btc })), (e) => e.key === 'metalProducts.notMetal');
});
it('weigert een asset dat niet bestaat', async () => {
await assert.rejects(() => createMetalProduct(geldig({ assetId: 9999 })), (e) => e.key === 'metalProducts.notMetal');
await assert.rejects(() => createMetalProduct(geldig({ assetId: null })), (e) => e.key === 'metalProducts.assetRequired');
});
it('persisteert', async () => {
const voor = harness.calls.persist;
await createMetalProduct(geldig());
assert.equal(harness.calls.persist, voor + 1);
});
});
describe('updateMetalProduct', () => {
it('overschrijft alle velden', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Oud', form: 'coin', fineGrams: 31 });
await updateMetalProduct(id, { assetId: zilver, name: 'Nieuw', form: 'bar', fineGrams: 1000 });
const p = harness.exports.one(`SELECT * FROM MetalProduct WHERE id = ?`, [id]);
assert.equal(p.assetId, zilver);
assert.equal(p.name, 'Nieuw');
assert.equal(p.form, 'bar');
assert.equal(p.fineGrams, 1000);
});
it('valideert met dezelfde regels als aanmaken', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
await assert.rejects(() => updateMetalProduct(id, { assetId: goud, name: '', form: 'coin', fineGrams: 31 }), (e) => e.key === 'metalProducts.nameRequired');
await assert.rejects(() => updateMetalProduct(id, { assetId: goud, name: 'X', form: 'ring', fineGrams: 31 }), /form/);
await assert.rejects(() => updateMetalProduct(id, { assetId: goud, name: 'X', form: 'coin', fineGrams: 0 }), (e) => e.key === 'metalProducts.fineGramsPositive');
assert.equal(harness.exports.one(`SELECT name FROM MetalProduct WHERE id = ?`, [id]).name, 'Krugerrand',
'de bestaande rij blijft ongemoeid als de validatie afketst');
});
});
describe('deleteMetalProduct', () => {
it('verwijdert een product dat nergens in gebruik is', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
await deleteMetalProduct(id);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM MetalProduct`).n, 0);
});
it('weigert zolang er een gebeurtenis naar verwijst', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
event({ productId: id, count: 2 });
await assert.rejects(() => deleteMetalProduct(id), (e) => e.key === 'metalProducts.inUse' && e.params.n === 1);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM MetalProduct`).n, 1);
});
it('noemt het aantal gebeurtenissen in de melding', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
event({ productId: id, count: 1 });
event({ productId: id, count: 1, at: '2024-04-01T00:00:00' });
await assert.rejects(() => deleteMetalProduct(id), (e) => e.key === 'metalProducts.inUse' && e.params.n === 2);
});
it('laat andere producten staan', async () => {
const een = await createMetalProduct({ assetId: goud, name: 'Een', form: 'coin', fineGrams: 31 });
const twee = await createMetalProduct({ assetId: goud, name: 'Twee', form: 'bar', fineGrams: 100 });
await deleteMetalProduct(een);
assert.deepEqual(listMetalProducts().map(p => p.id), [twee]);
});
});
+364
View File
@@ -0,0 +1,364 @@
// Generated, do not edit. sha256:490eb789a7955706a4dfbdb270c3afd6e01efb01380d72bede04f23bdd22cce9
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { ENUMS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
dismissNotification, listActiveNotifications, notificationDetail, currentNotifications,
refreshNotifications,
} = await import('../../server/data/notifications.js');
const { createSchedule } = await import('../../server/data/interest-schedules.js');
const { createValuation } = await import('../../server/data/valuations.js');
const { createReservation } = await import('../../server/data/reservations.js');
let ctx;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
const btc = harness.exports.lastId();
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Kluis','kluis','wallet',1,1,1,NULL)`);
ctx = { btc, account: harness.exports.lastId() };
});
const rijen = () => harness.exports.all(
`SELECT id, kind, count, highWater, dismissedAt FROM Notification ORDER BY id`);
const actief = (kind) => listActiveNotifications().find(n => n.kind === kind) || null;
function voorstel({ amount = 100_000, status = 'pending' } = {}) {
harness.exports.run(
`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, NULL, NULL, ?, 'in', 'transfer', ?, '2024-05-01T00:00:00')`,
[ctx.account, ctx.btc, amount, status]);
return harness.exports.lastId();
}
const waardering = async (extra = {}) => {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2024-06-01T10:00:00', 100000, 50000, ?, 'manual', '2024-06-01T10:00:00')`,
[ctx.btc, ctx.account]);
return createValuation({
assetId: ctx.btc, valuedOn: '2024-12-31', accountId: ctx.account,
balance: 500_000, fiatRate: 90_000, rateAt: '2024-12-31T23:00:00', source: 'manual', ...extra,
});
};
const afgelopenAfspraak = (endsOn = '2020-06-30') => createSchedule({
accountId: ctx.account, rateKind: 'fixed',
principalAssetId: ctx.btc, payoutAssetId: ctx.btc,
principalAmount: 100_000_000, annualRate: 0.05,
startsOn: '2019-01-01', endsOn: endsOn,
creditCadence: 'monthly', creditDayOfMonth: 1, note: null,
});
function voorstelBron() {
harness.exports.run(
`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) VALUES ('test','test','test','https://test.test','none',0,1,0,0,'2024-01-01T00:00:00')`);
const provider = harness.exports.lastId();
harness.exports.run(
`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
VALUES (?, 'spot', ?, 1, 'proposed')`, [ctx.btc, provider]);
return harness.exports.lastId();
}
function aiVoorstel({ status = 'pending' } = {}) {
harness.exports.run(
`INSERT INTO AiProposal (assetId, kind, fiatPrice, observedAt, note, status, createdAt)
VALUES (?, 'price_point', 42000, '2024-01-01T00:00:00Z', NULL, ?, '2024-01-01T00:00:00')`,
[ctx.btc, status]);
return harness.exports.lastId();
}
describe('refreshNotifications: leidt af uit wat er openstaat', () => {
it('maakt geen melding als er niets openstaat', async () => {
await refreshNotifications();
assert.deepEqual(rijen(), []);
});
it('maakt één melding met de count en de high-water van dat soort', async () => {
const id = voorstel();
await refreshNotifications();
const n = actief('reconcile');
assert.ok(n, 'er hoort een reconcile-melding te staan');
assert.equal(n.count, 1);
assert.equal(n.highWater, id, 'high-water van reconcile is het hoogste voorstel-id');
});
it('is idempotent: twee keer draaien verandert de tabel niet', async () => {
voorstel();
await refreshNotifications();
const na1 = rijen();
await refreshNotifications();
assert.deepEqual(rijen(), na1);
});
it('schrijft niet naar de database als er niets verandert', async () => {
voorstel();
await refreshNotifications();
const voor = harness.calls.persist;
await refreshNotifications();
assert.equal(harness.calls.persist, voor, 'ongewijzigde staat ⇒ geen persist');
});
it('werkt count en high-water bij in dezelfde rij', async () => {
voorstel();
await refreshNotifications();
const eerste = actief('reconcile');
const tweede = voorstel({ amount: 200_000 });
await refreshNotifications();
const nu = actief('reconcile');
assert.equal(nu.id, eerste.id, 'geen tweede melding: dezelfde rij wordt bijgewerkt');
assert.equal(nu.count, 2);
assert.equal(nu.highWater, tweede);
});
});
describe('dismissNotification: weg tot er iets nieuwer is', () => {
it('haalt de melding uit de actieve lijst maar bewaart de rij', async () => {
voorstel();
await refreshNotifications();
const n = actief('reconcile');
await dismissNotification(n.id);
assert.equal(actief('reconcile'), null, 'niet meer actief');
assert.equal(rijen().length, 1, 'de rij blijft staan: hij is de geschiedenis');
assert.ok(rijen()[0].dismissedAt, 'met een dismissedAt-stempel');
});
it('brengt een weggeklikte melding NIET terug bij dezelfde high-water', async () => {
voorstel();
await refreshNotifications();
await dismissNotification(actief('reconcile').id);
await refreshNotifications();
assert.equal(actief('reconcile'), null, 'hetzelfde voorstel is geen nieuws');
assert.equal(rijen().length, 1);
});
it('maakt wél een nieuwe melding zodra de high-water hoger is', async () => {
voorstel();
await refreshNotifications();
await dismissNotification(actief('reconcile').id);
const nieuwer = voorstel({ amount: 300_000 });
await refreshNotifications();
const n = actief('reconcile');
assert.ok(n, 'iets nieuwers hoort weer een melding te geven');
assert.equal(n.highWater, nieuwer);
assert.equal(n.count, 2, 'de count telt alles wat openstaat, niet alleen het nieuwe');
assert.equal(rijen().length, 2, 'de weggeklikte rij blijft ernaast staan');
});
it('doet niets bij een id dat al weggeklikt is', async () => {
voorstel();
await refreshNotifications();
const n = actief('reconcile');
await dismissNotification(n.id);
const stempel = rijen()[0].dismissedAt;
await dismissNotification(n.id);
assert.equal(rijen()[0].dismissedAt, stempel, 'de eerste stempel blijft staan');
});
});
describe('opruimen: actie-soorten wel, info-soorten niet', () => {
it('ruimt een actie-melding op zodra er niets meer openstaat', async () => {
const id = voorstel();
await refreshNotifications();
assert.ok(actief('reconcile'));
harness.exports.run(`DELETE FROM ReconcileProposal WHERE id = ?`, [id]);
await refreshNotifications();
assert.equal(actief('reconcile'), null);
assert.equal(rijen().length, 0, 'de rij wordt verwijderd, niet weggeklikt');
});
it('laat een info-melding staan als de bron verdwijnt', async () => {
const id = await afgelopenAfspraak();
await refreshNotifications();
assert.ok(actief('schedule_ended'), 'afgelopen afspraak geeft een melding');
harness.exports.run(`DELETE FROM InterestSchedule WHERE id = ?`, [id]);
await refreshNotifications();
assert.ok(actief('schedule_ended'), 'info-melding blijft staan');
});
it('geeft een verwijderde actie-melding opnieuw bij een nieuw item', async () => {
const id = voorstel();
await refreshNotifications();
harness.exports.run(`DELETE FROM ReconcileProposal WHERE id = ?`, [id]);
await refreshNotifications();
voorstel({ amount: 400_000 });
await refreshNotifications();
assert.ok(actief('reconcile'), 'nieuw voorstel ⇒ nieuwe melding');
});
});
describe('notificationDetail', () => {
it('geeft null voor een actie-soort', async () => {
voorstel();
await refreshNotifications();
assert.equal(notificationDetail(actief('reconcile')), null,
'actie-soorten tonen een vaste hint, geen afgeleid detail');
});
it('geeft bij schedule_ended de laatst afgelopen afspraak plus hoeveel er nog meer zijn', async () => {
await afgelopenAfspraak('2020-06-30');
await afgelopenAfspraak('2022-03-31');
await refreshNotifications();
const d = notificationDetail(actief('schedule_ended'));
assert.equal(d.account, 'Kluis');
assert.equal(d.asset, 'BTC');
assert.equal(d.extra, 1, 'één andere afgelopen afspraak');
assert.equal(d.accountId, ctx.account);
});
it('geeft null als er niets (meer) afgelopen is', async () => {
const id = await afgelopenAfspraak();
await refreshNotifications();
harness.exports.run(`DELETE FROM InterestSchedule WHERE id = ?`, [id]);
assert.equal(notificationDetail(actief('schedule_ended')), null);
});
});
describe('currentNotifications', () => {
it('leidt af en geeft terug, zonder dat de aanroeper eerst moet verversen', async () => {
voorstel();
voorstel({ amount: 2 });
const uit = await currentNotifications();
const reconcile = uit.find(n => n.kind === 'reconcile');
assert.ok(reconcile, 'zonder losse refreshNotifications-aanroep');
assert.equal(reconcile.count, 2);
});
it('laat een weggeklikte melding weg, en dát is de gedeelde "gezien"', async () => {
voorstel();
await refreshNotifications();
await dismissNotification(actief('reconcile').id);
const uit = await currentNotifications();
assert.equal(uit.some(n => n.kind === 'reconcile'), false);
});
it('meldt niets voor een afgehandeld voorstel', async () => {
voorstel({ status: 'auto_linked' });
const uit = await currentNotifications();
assert.equal(uit.some(n => n.kind === 'reconcile'), false);
});
it('is idempotent: twee keer aanroepen geeft dezelfde stand', async () => {
voorstel();
const een = await currentNotifications();
const twee = await currentNotifications();
assert.deepEqual(twee.map(n => [n.kind, n.count]), een.map(n => [n.kind, n.count]));
});
});
describe('elke soort uit de enum heeft een werkende bron', () => {
it('levert voor elke soort uit de enum een melding als er iets openstaat', async () => {
voorstel();
await waardering();
await afgelopenAfspraak();
voorstelBron();
aiVoorstel();
await createSchedule({
accountId: ctx.account, rateKind: 'fixed',
principalAssetId: ctx.btc, payoutAssetId: ctx.btc,
principalAmount: 100_000_000, annualRate: 0.05,
startsOn: '2024-01-01', endsOn: '2099-12-31',
creditCadence: 'monthly', creditDayOfMonth: 1, note: null,
});
await createReservation({ accountId: ctx.account, assetId: ctx.btc, direction: 'out', amount: 1000,
occurredAt: '2024-06-01T00:00:00Z', note: null });
await refreshNotifications();
const soorten = listActiveNotifications().map(n => n.kind).sort();
assert.deepEqual(soorten, [...ENUMS.NOTIFICATION_KIND].sort(),
'elke soort uit de enum hoort een melding te kunnen maken');
});
it('meldt een openstaand AI-voorstel, en juist géén afgehandeld voorstel', async () => {
const wacht = aiVoorstel();
aiVoorstel({ status: 'accepted' });
aiVoorstel({ status: 'rejected' });
await refreshNotifications();
const n = actief('ai_proposal');
assert.ok(n, 'het openstaande voorstel levert een kaart op');
assert.equal(n.count, 1, 'en de afgehandelde tellen niet mee');
assert.equal(n.highWater, wacht);
});
it('haalt de melding weg zodra het voorstel is afgehandeld', async () => {
const id = aiVoorstel();
await refreshNotifications();
assert.ok(actief('ai_proposal'));
harness.exports.run(`UPDATE AiProposal SET status = 'accepted' WHERE id = ?`, [id]);
await refreshNotifications();
assert.equal(actief('ai_proposal'), null);
});
it('haalt de melding weg zodra de AI-koppeling op inactief staat', async () => {
const { setPref } = await import('../../server/data/preferences.js');
aiVoorstel();
await refreshNotifications();
assert.ok(actief('ai_proposal'));
await setPref('ai', 'enabled', 0);
await refreshNotifications();
assert.equal(actief('ai_proposal'), null, 'met de koppeling uit hoort er geen kaart te staan');
await setPref('ai', 'enabled', 1);
await refreshNotifications();
assert.ok(actief('ai_proposal'), 'en met de koppeling weer aan komt hij terug');
});
it('leidt NIET meer af uit een opdracht zonder verlooptijd', async () => {
harness.exports.run(
`INSERT INTO Command (kind, payload, state, expiresAt, createdAt)
VALUES ('navigate', NULL, 'open', NULL, '2024-01-01T00:00:00')`);
await refreshNotifications();
assert.equal(actief('ai_proposal'), null, 'een opdracht is geen voorstel');
assert.deepEqual(rijen(), [], 'en levert dus helemaal geen melding op');
});
it('meldt géén verschil bij een balans zonder grootboek eronder', async () => {
await createValuation({
assetId: ctx.btc, valuedOn: '2024-12-31', accountId: ctx.account,
balance: 500_000, fiatRate: 90_000, rateAt: '2024-12-31T23:00:00', source: 'manual',
});
await refreshNotifications();
assert.equal(listActiveNotifications().some(n => n.kind === 'valuation'), false);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, createdAt)
VALUES (?, 'buy', '2024-06-01T10:00:00', 100000, 50000, ?, 'manual', '2024-06-01T10:00:00')`,
[ctx.btc, ctx.account]);
await refreshNotifications();
assert.equal(listActiveNotifications().some(n => n.kind === 'valuation'), true);
});
it('telt een lightning-account waarvan de meting afwijkt van het grootboek mee', async () => {
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, balanceSats, lastScannedAt, createdAt)
VALUES (?, 'nwc', 'pubkey', 21000, '2026-10-19T10:00:00Z', '2026-01-01')`, [ctx.account]);
await refreshNotifications();
assert.equal(actief('valuation').count, 1);
});
it('sorteert de actieve lijst nieuwste eerst', async () => {
voorstel();
await waardering();
await refreshNotifications();
const ids = listActiveNotifications().map(n => n.id);
assert.deepEqual(ids, [...ids].sort((a, b) => b - a), 'aflopend op id');
});
});
+62
View File
@@ -0,0 +1,62 @@
// Generated, do not edit. sha256:69bbcbe886b727fc98661cf558624d706763f80b36c5e23baab99e9c498f13cc
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG } from '../../shared/config.js';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { getBaseCurrency } = await import('../../server/data/portfolio.js');
const { getSettings, updateSettings } = await import('../../server/data/preferences.js');
const { listBookableAssets } = await import('../../server/data/assets.js');
beforeEach(() => harness.recreate());
describe('getBaseCurrency', () => {
it('levert de munt van de database', () => {
assert.equal(getBaseCurrency(), CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
});
it('leest de database en niet een standaardwaarde', () => {
harness.sqlite.exec(`DROP TRIGGER TrPortfolioDelete; DELETE FROM Portfolio;
INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, 'USD', NULL)`);
assert.equal(getBaseCurrency(), 'USD');
});
it('weigert een database zonder munt, in plaats van EUR aan te nemen', () => {
harness.sqlite.exec(`DROP TRIGGER TrPortfolioDelete; DELETE FROM Portfolio`);
assert.throws(() => getBaseCurrency(), /no unit of account/);
});
});
describe('de keuzelijsten volgen de rekenvaluta van de database', () => {
const munt = (symbol) => harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?, ?, ?, 'fiat', ?, 'cent', 2, 'unit')`, [symbol, symbol, symbol.toLowerCase(), symbol]);
const symbolen = () => listBookableAssets().map(a => a.symbol);
it('in euro: de euro kiesbaar, een ongebruikte dollar niet', () => {
munt('EUR'); munt('USD');
assert.deepEqual(symbolen(), ['EUR']);
});
it('in dollars: andersom', () => {
harness.sqlite.exec(`DROP TRIGGER TrPortfolioDelete; DELETE FROM Portfolio;
INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, 'USD', NULL)`);
munt('EUR'); munt('USD');
assert.deepEqual(symbolen(), ['USD']);
});
});
describe('getSettings: de munt rijdt mee, maar is geen instelling', () => {
it('geeft de munt mee in de momentopname', () => {
assert.equal(getSettings().baseCurrency, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
});
it('laat hem niet via updateSettings wijzigen', async () => {
await assert.rejects(() => updateSettings({ baseCurrency: 'USD' }), (e) => e.key === 'settings.unknownSetting');
assert.equal(getBaseCurrency(), CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY);
});
});
+630
View File
@@ -0,0 +1,630 @@
// Generated, do not edit. sha256:f9e5d8040f9c1061da0dda73705bf0d7c641408f2ad4f292363d7850e191ceff
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { toSearchName } from '../../shared/search-name.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { assetPositions, eventsNetByAccount } = await import('../../server/data/events.js');
const { accountHoldings, accountPositions, assetPositionsByAccount, firstPositionDate, positionAt, positionsByAsset,
unexplainedByPair, unexplainedChange, lightningBalanceDiffs, recordLightningBalance } = await import('../../server/data/positions.js');
const { anchorAt, latestAnchors } = await import('../../server/data/valuations.js');
const { lightningBalances } = await import('../../server/data/wallet-sources.js');
const { run, lastId } = harness.exports;
const net = (symbol) => positionsByAsset().find(p => p.symbol === symbol).net;
let btc, eur, kluis, bank;
beforeEach(() => {
harness.recreate();
btc = asset('BTC', 'Bitcoin', 'crypto', 8);
eur = asset('EUR', 'Euro', 'fiat', 2);
kluis = account('Kluis');
bank = account('Spaarrekening');
});
function asset(symbol, naam, kind, decimals) {
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?,?,?,?,?,NULL,?,'unit')`, [symbol, naam, toSearchName(naam), kind, symbol, decimals]);
return lastId();
}
function account(naam) {
run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES (?,?,'bank',1,0,1,NULL)`, [naam, toSearchName(naam)]);
return lastId();
}
function event({ assetId = null, type = 'buy', to = null, from = null, amount = 1000, unitFee = null,
at = '2026-03-01T12:00:00.000Z' } = {}) {
run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,'manual',?)`,
[assetId ?? btc, type, at, amount, type === 'buy' || type === 'sell' ? 50000 : null, unitFee, from, to, at]);
return lastId();
}
function balans({ assetId = null, acc = undefined, balance = 5000, at = '2026-06-30T00:00:00.000Z' } = {}) {
const accountId = acc === undefined ? bank : acc;
run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,1.0,?, 'manual')`,
[assetId ?? eur, at.slice(0, 10), accountId, balance, at]);
return lastId();
}
describe('positionAt zonder anker', () => {
it('is gelijk aan de som van de gebeurtenissen, met en zonder peilmoment', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ to: kluis, amount: 50_000, at: '2026-05-01T00:00:00.000Z' });
for (const peil of [null, '2026-03-01T00:00:00.000Z', '2026-12-31T00:00:00.000Z']) {
assert.equal(positionAt(kluis, btc, peil), eventsNetByAccount(kluis, btc, peil), `peil ${peil}`);
}
assert.equal(positionAt(kluis, btc, null), 150_000, 'en dat is ook echt het saldo');
});
it('geeft nul voor een rekening zonder enige beweging', () => {
assert.equal(positionAt(bank, eur, null), 0);
});
it('ziet een waardering van een ánder asset of een ándere rekening niet als anker', () => {
balans({ acc: bank, assetId: eur, balance: 999_999 });
event({ to: kluis, amount: 100_000 });
assert.equal(positionAt(kluis, btc, null), 100_000, 'ander asset én andere rekening');
assert.equal(anchorAt(kluis, btc, null), null);
assert.equal(anchorAt(bank, btc, null), null, 'zelfde rekening, ander asset');
assert.equal(anchorAt(kluis, eur, null), null, 'zelfde asset, andere rekening');
});
it('negeert een portefeuille-brede waardering, want die zegt niets over één rekening', () => {
balans({ acc: null, assetId: eur, balance: 999_999 });
event({ assetId: eur, to: bank, amount: 2500 });
assert.equal(anchorAt(bank, eur, null), null);
assert.equal(positionAt(bank, eur, null), 2500);
});
});
describe('positionAt met een anker', () => {
it('geeft de balans zelf als er niets ná het anker gebeurde', () => {
balans({ balance: 123_456 });
assert.equal(positionAt(bank, eur, null), 123_456);
assert.equal(eventsNetByAccount(bank, eur, null), 0, 'en het grootboek is nog steeds leeg');
});
it('telt de bewegingen ná het anker erbovenop', () => {
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_500, 'rente ná de balans komt erbij');
});
it('negeert de bewegingen vóór het anker, ook als ze niet kloppen', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_000);
});
it('rekent een gebeurtenis exact op rateAt ín het anker, en niet erbovenop', () => {
const moment = '2026-06-30T00:00:00.000Z';
event({ assetId: eur, to: bank, amount: 7_000, at: moment });
balans({ balance: 100_000, at: moment });
assert.equal(positionAt(bank, eur, null), 100_000);
});
it('kijkt naar rateAt en niet naar valuedOn', () => {
run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?, '2026-06-30', ?, 100000, 1.0, '2026-07-01T12:00:00.000Z', 'manual')`, [eur, bank]);
event({ assetId: eur, to: bank, amount: 7_000, at: '2026-07-01T09:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_000);
});
it('neemt bij twee ankers de nieuwste vóór het peilmoment', () => {
balans({ balance: 100_000, at: '2026-03-31T00:00:00.000Z' });
balans({ balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, '2026-05-01T00:00:00.000Z'), 100_000, 'de oudste geldt in mei');
assert.equal(positionAt(bank, eur, null), 200_000, 'en nu de nieuwste');
});
it('kijkt daarbij naar het peilmoment en niet naar de invoervolgorde', () => {
balans({ balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-03-31T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 200_000, 'juni is het nieuwste anker, ook al is maart later ingevoerd');
assert.equal(anchorAt(bank, eur, null).balance, 200_000);
});
it('laat een anker exact óp het peilmoment gelden', () => {
const moment = '2026-06-30T00:00:00.000Z';
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: moment });
assert.equal(positionAt(bank, eur, moment), 100_000);
});
it('negeert een anker dat ná het peilmoment ligt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, '2026-02-01T00:00:00.000Z'), 40_000);
});
it('kan een negatieve uitkomst geven als er ná het anker meer af gaat dan erop staat', () => {
balans({ balance: 1_000 });
event({ assetId: eur, type: 'sell', from: bank, amount: 3_000, at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), -2_000);
});
});
describe('positionAt hergebruikt de tekenregels van het grootboek', () => {
it('laat de unitFee van een uitgaande verplaatsing ná het anker meedrukken', () => {
balans({ balance: 100_000 });
event({ assetId: eur, type: 'transfer', from: bank, to: kluis, amount: 10_000, unitFee: 250,
at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 89_750);
});
it('rekent een zelf-transfer ná het anker als alleen de fee', () => {
balans({ balance: 100_000 });
event({ assetId: eur, type: 'transfer', from: bank, to: bank, amount: 10_000, unitFee: 250,
at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 99_750);
});
it('laat een boeking zónder rekening buiten de stand van elke rekening', () => {
balans({ balance: 100_000 });
event({ assetId: eur, amount: 60_000, at: '2026-08-01T00:00:00.000Z' });
assert.equal(positionAt(bank, eur, null), 100_000);
assert.equal(positionAt(kluis, eur, null), 0);
});
});
describe('latestAnchors', () => {
it('geeft per rekening en asset de nieuwste, en laat de portefeuille-brede rijen weg', () => {
balans({ acc: bank, balance: 100_000, at: '2026-03-31T00:00:00.000Z' });
balans({ acc: bank, balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
balans({ acc: kluis, assetId: btc, balance: 50_000, at: '2026-01-01T00:00:00.000Z' });
balans({ acc: null, balance: 999_999 });
assert.deepEqual(
latestAnchors().map(a => ({ accountId: a.accountId, assetId: a.assetId, balance: a.balance })),
[{ accountId: kluis, assetId: btc, balance: 50_000 }, { accountId: bank, assetId: eur, balance: 200_000 }]);
});
it('is leeg zonder waarderingen', () => {
assert.deepEqual(latestAnchors(), []);
});
});
describe('positionsByAsset', () => {
it('is gelijk aan het rauwe aggregaat zolang er geen anker is', () => {
event({ to: kluis, amount: 100_000 });
event({ assetId: eur, to: bank, amount: 2_500 });
assert.deepEqual(positionsByAsset(), assetPositions());
});
it('corrigeert de stand van een asset met een anker', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
assert.equal(net('EUR'), 100_500, 'de balans plus de rente erna');
assert.equal(assetPositions().find(p => p.symbol === 'EUR').net, 40_500, 'het rauwe grootboek zegt iets anders');
});
it('telt de ankers van twee rekeningen bij elkaar op', () => {
const tweede = account('Tweede rekening');
balans({ acc: bank, balance: 100_000 });
balans({ acc: tweede, balance: 25_000 });
assert.equal(net('EUR'), 125_000);
});
it('houdt een boeking zónder rekening in het vermogenstotaal', () => {
event({ assetId: eur, amount: 60_000, at: '2026-01-01T00:00:00.000Z' });
balans({ acc: bank, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(net('EUR'), 160_000, 'het anker van de bank plus de rekeningloze boeking');
});
it('laat een portefeuille-brede balans het totaal niet verschuiven', () => {
event({ assetId: eur, to: bank, amount: 2_500 });
balans({ acc: null, balance: 999_999 });
assert.equal(net('EUR'), 2_500);
});
it('laat de koop-aggregaten ongemoeid', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 100_000 });
const rij = positionsByAsset().find(p => p.symbol === 'EUR');
const rauw = assetPositions().find(p => p.symbol === 'EUR');
assert.equal(rij.buy_events, rauw.buy_events);
assert.equal(rij.buy_amount, rauw.buy_amount);
assert.equal(rij.buy_spot_raw, rauw.buy_spot_raw);
});
it('komt uit op de som van de standen per rekening plus wat geen rekening heeft', () => {
const tweede = account('Tweede rekening');
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-01-01T00:00:00.000Z' });
event({ assetId: eur, to: tweede, amount: 7_000, at: '2026-02-01T00:00:00.000Z' });
event({ assetId: eur, amount: 60_000, at: '2026-02-15T00:00:00.000Z' });
balans({ acc: bank, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
const perRekening = positionAt(bank, eur, null) + positionAt(tweede, eur, null);
assert.equal(net('EUR'), perRekening + 60_000);
});
});
describe('accountPositions', () => {
it('laat een asset weg waar niets meer op staat', () => {
event({ assetId: eur, to: bank, amount: 1_000, at: '2026-01-01T00:00:00.000Z' });
event({ assetId: eur, type: 'sell', from: bank, amount: 1_000, at: '2026-02-01T00:00:00.000Z' });
assert.deepEqual(accountPositions(bank), []);
});
it('ziet een rekening die alleen een balans heeft, en dat is de hele reden', () => {
balans({ balance: 123_456 });
assert.deepEqual(accountPositions(bank), [{ assetId: eur, saldo: 123_456 }]);
});
it('geeft meerdere assets terug, op id gesorteerd', () => {
event({ to: bank, amount: 100_000 });
balans({ balance: 5_000 });
assert.deepEqual(accountPositions(bank), [{ assetId: btc, saldo: 100_000 }, { assetId: eur, saldo: 5_000 }]);
});
it('houdt de rekeningen gescheiden', () => {
balans({ acc: bank, balance: 5_000 });
event({ to: kluis, amount: 100_000 });
assert.deepEqual(accountPositions(kluis), [{ assetId: btc, saldo: 100_000 }]);
});
});
describe('accountHoldings', () => {
it('geeft het saldo van nu en de nieuwste balans eronder, niet de boeking erna', () => {
balans({ balance: 5_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'sell', from: bank, amount: 1_000, at: '2026-07-15T12:00:00.000Z' });
assert.deepEqual(accountHoldings([bank]), [{ accountId: bank, assetId: eur, saldo: 4_000,
anchor: { valuedOn: '2026-06-30', rateAt: '2026-06-30T00:00:00.000Z', balance: 5_000 } }]);
});
it('geeft geen anker voor een stand uit alleen boekingen, en volgt de volgorde van de vraag', () => {
event({ to: kluis, amount: 100_000 });
balans({ acc: bank, balance: 7_000 });
const rijen = accountHoldings([kluis, bank]);
assert.deepEqual(rijen.map(r => r.accountId), [kluis, bank]);
assert.equal(rijen[0].anchor, null);
for (const id of [kluis, bank]) {
assert.deepEqual(rijen.filter(r => r.accountId === id).map(({ assetId, saldo }) => ({ assetId, saldo })),
accountPositions(id), `hetzelfde saldo als accountPositions voor ${id}`);
}
});
it('laat een leeg paar weg, ook als er een balans van nul onder staat', () => {
balans({ balance: 0 });
event({ to: kluis, amount: 1_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'sell', from: kluis, amount: 1_000, at: '2026-02-01T00:00:00.000Z' });
assert.deepEqual(accountHoldings([bank, kluis]), []);
});
it('ziet een balans in de toekomst nog niet als anker van nu', () => {
balans({ balance: 5_000, at: '2026-01-01T00:00:00.000Z' });
balans({ balance: 9_999, at: '2099-01-01T00:00:00.000Z' });
const [rij] = accountHoldings([bank]);
assert.equal(rij.saldo, 5_000);
assert.equal(rij.anchor.valuedOn, '2026-01-01');
});
});
describe('assetPositionsByAccount', () => {
const som = rijen => rijen.reduce((s, r) => s + r.saldo, 0);
it('splitst per rekening, de grootste eerst', () => {
event({ to: kluis, amount: 100_000 });
event({ to: bank, amount: 300_000 });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: bank, saldo: 300_000 }, { accountId: kluis, saldo: 100_000 }]);
assert.equal(som(rijen), net('BTC'), 'telt op tot de stand van het asset');
});
it('geeft een lege lijst voor een asset waar niets van is', () => {
assert.deepEqual(assetPositionsByAccount(btc), []);
});
it('laat een rekening weg die per saldo leeg is', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'sell', from: kluis, amount: 100_000, at: '2026-02-01T00:00:00.000Z' });
assert.deepEqual(assetPositionsByAccount(btc), []);
});
it('ziet een rekening die alleen een balans heeft', () => {
balans({ acc: bank, assetId: eur, balance: 123_456 });
const rijen = assetPositionsByAccount(eur);
assert.deepEqual(rijen, [{ accountId: bank, saldo: 123_456 }]);
assert.equal(som(rijen), net('EUR'));
});
it('houdt een verplaatsing tussen twee eigen rekeningen kloppend', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'transfer', from: kluis, to: bank, amount: 40_000, at: '2026-02-01T00:00:00.000Z' });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: kluis, saldo: 60_000 }, { accountId: bank, saldo: 40_000 }]);
assert.equal(som(rijen), net('BTC'), 'een verplaatsing verandert het totaal niet');
});
it('zet een aankoop zonder rekening in een eigen rij, achteraan', () => {
event({ to: kluis, amount: 100_000 });
event({ amount: 50_000 });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: kluis, saldo: 100_000 }, { accountId: null, saldo: 50_000 }]);
assert.equal(som(rijen), net('BTC'));
});
it('verbergt een negatieve rest niet', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ type: 'sell', amount: 40_000, at: '2026-02-01T00:00:00.000Z' });
const rijen = assetPositionsByAccount(btc);
assert.deepEqual(rijen, [{ accountId: kluis, saldo: 100_000 }, { accountId: null, saldo: -40_000 }]);
assert.equal(som(rijen), net('BTC'), 'en de som klopt nog steeds');
});
it('blijft kloppen met een anker erbij', () => {
event({ assetId: eur, to: bank, amount: 1_000, at: '2026-01-01T00:00:00.000Z' });
balans({ acc: bank, assetId: eur, balance: 5_000, at: '2026-06-30T00:00:00.000Z' });
const rijen = assetPositionsByAccount(eur);
assert.deepEqual(rijen, [{ accountId: bank, saldo: 5_000 }]);
assert.equal(som(rijen), net('EUR'), 'geen restrij, want het anker zit in allebei de getallen');
});
it('respecteert het peilmoment', () => {
event({ to: kluis, amount: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ to: bank, amount: 300_000, at: '2026-05-01T00:00:00.000Z' });
assert.deepEqual(assetPositionsByAccount(btc, '2026-03-01T00:00:00.000Z'),
[{ accountId: kluis, saldo: 100_000 }], 'de tweede rekening bestond toen nog niet');
});
});
describe('firstPositionDate', () => {
it('is null als er niets is', () => {
assert.equal(firstPositionDate(), null);
});
it('neemt de eerste gebeurtenis als er geen anker is', () => {
event({ to: kluis, amount: 1_000, at: '2022-06-01T00:00:00' });
assert.equal(firstPositionDate(), '2022-06-01T00:00:00');
});
it('neemt het eerste anker als er geen gebeurtenis is', () => {
balans({ at: '2026-06-30T00:00:00.000Z' });
assert.equal(firstPositionDate(), '2026-06-30T00:00:00.000Z');
});
it('neemt de vroegste van de twee, welke van de twee dat ook is', () => {
event({ to: kluis, amount: 1_000, at: '2022-06-01T00:00:00' });
balans({ at: '2026-06-30T00:00:00.000Z' });
assert.equal(firstPositionDate(), '2022-06-01T00:00:00', 'de gebeurtenis was eerder');
harness.exports.run(`DELETE FROM Event`);
event({ to: kluis, amount: 1_000, at: '2027-01-01T00:00:00' });
assert.equal(firstPositionDate(), '2026-06-30T00:00:00.000Z', 'nu was het anker eerder');
});
it('kijkt niet naar een portefeuille-brede balans', () => {
balans({ acc: null, at: '2020-01-01T00:00:00.000Z' });
event({ to: kluis, amount: 1_000, at: '2022-06-01T00:00:00' });
assert.equal(firstPositionDate(), '2022-06-01T00:00:00');
});
});
describe('unexplainedChange', () => {
const jaar = ['2026-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'];
it('is nul zonder anker, wat er ook geboekt is', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
event({ assetId: eur, type: 'sell', from: bank, amount: 10_000, at: '2026-05-01T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 0);
});
it('is de sprong die het anker maakt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 60_000);
});
it('meldt niets als de balans het grootboek bevestigt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 40_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 0);
});
it('rekent de sprong toe aan het jaar waarin hij valt en niet aan het jaar erna', () => {
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 100_000, 'in 2026 wél');
assert.equal(unexplainedChange(bank, eur, '2027-01-01T00:00:00.000Z', '2028-01-01T00:00:00.000Z'), 0,
'in 2027 niet meer');
});
it('telt de rente ná het anker niet als onverklaard', () => {
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, type: 'interest', to: bank, amount: 500, at: '2026-07-31T00:00:00.000Z' });
assert.equal(unexplainedChange(bank, eur, ...jaar), 100_000, 'alleen de sprong van het anker zelf');
});
it('houdt rekeningen en assets gescheiden', () => {
balans({ acc: bank, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.equal(unexplainedChange(kluis, eur, ...jaar), 0, 'andere rekening');
assert.equal(unexplainedChange(bank, btc, ...jaar), 0, 'ander asset');
});
});
describe('unexplainedByPair', () => {
const jaar = ['2026-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z'];
it('geeft niets terug als er niets onverklaard is', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 40_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar), [], 'de balans bevestigt het grootboek');
});
it('noemt het paar, het aantal eenheden en wat daarvan uit een beginbalans komt', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 0, hasLedger: true }],
'er stond een boeking vóór deze balans, dus dit is een verschil dat te boeken valt');
});
it('rekent een balans zónder boekingen ervóór toe aan `openingUnits`', () => {
balans({ acc: kluis, assetId: btc, balance: 5_000_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: kluis, assetId: btc, units: 5_000_000, openingUnits: 5_000_000, hasLedger: false }]);
});
it('blijft een beginbalans noemen als er ná die balans wél geboekt is', () => {
balans({ balance: 159_743, at: '2026-02-01T00:00:00.000Z' });
event({ assetId: eur, to: bank, amount: 380_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 539_743, at: '2026-04-01T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 159_743, openingUnits: 159_743, hasLedger: true }]);
});
it('kijkt naar het grootboek van dát paar en niet van de rekening', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ acc: bank, assetId: btc, balance: 5_000_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: btc, units: 5_000_000, openingUnits: 5_000_000, hasLedger: false }]);
});
it('geeft een negatief bedrag als de boekingen méér verklaren dan de stand veranderde', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
event({ assetId: eur, to: bank, amount: 60_000, at: '2027-03-01T00:00:00.000Z' });
balans({ balance: 100_000, at: '2027-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair('2027-01-01T00:00:00.000Z', '2028-01-01T00:00:00.000Z'),
[{ accountId: bank, assetId: eur, units: -60_000, openingUnits: 0, hasLedger: true }]);
});
it('rekent een balans exact op het beginmoment aan het jaar ervóór toe', () => {
balans({ balance: 100_000, at: '2026-01-01T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar), [], 'in 2026 niet meer');
assert.deepEqual(unexplainedByPair('2025-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'),
[{ accountId: bank, assetId: eur, units: 100_000, openingUnits: 100_000, hasLedger: false }],
'maar in 2025 wél, want dáár sprong de stand');
});
it('laat die balans ook het afgesplitste deel van het nieuwe jaar niet vervuilen', () => {
balans({ balance: 100_000, at: '2026-01-01T00:00:00.000Z' });
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
balans({ balance: 200_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 0, hasLedger: true }]);
});
it('telt een AANVAARD verschil net zo goed als instap', () => {
event({ assetId: eur, to: bank, amount: 40_000, at: '2026-03-01T00:00:00.000Z' });
const id = balans({ balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 0, hasLedger: true }],
'vooraf: gewoon een verschil');
run(`UPDATE Valuation SET gapAcceptedAt = '2026-07-01T00:00:00.000Z' WHERE id = ?`, [id]);
assert.deepEqual(unexplainedByPair(...jaar),
[{ accountId: bank, assetId: eur, units: 60_000, openingUnits: 60_000, hasLedger: true }],
'erna: hetzelfde bedrag, maar nu als stroom');
});
it('laat een portefeuille-brede balans buiten de uitsplitsing', () => {
balans({ acc: null, balance: 100_000, at: '2026-06-30T00:00:00.000Z' });
assert.deepEqual(unexplainedByPair(...jaar), []);
});
});
describe('een balans in de toekomst', () => {
const morgen = new Date(Date.now() + 36 * 3600 * 1000).toISOString();
const gisteren = new Date(Date.now() - 36 * 3600 * 1000).toISOString();
it('raakt de stand van nu niet, en geldt wél op een peilmoment erná', () => {
event({ assetId: eur, to: bank, amount: 10_000, at: gisteren });
balans({ balance: 999_999, at: morgen });
assert.equal(positionAt(bank, eur, null), 999_999, 'zonder peilmoment is het "alles", inclusief de toekomst');
assert.equal(net('EUR'), 10_000, 'maar het portefeuille-totaal van nu kent hem niet');
assert.deepEqual(accountPositions(bank), [{ assetId: eur, saldo: 10_000 }], 'en de stand per rekening ook niet');
});
it('wordt wél het anker als je een peilmoment ná die datum vraagt', () => {
balans({ balance: 999_999, at: morgen });
assert.equal(net('EUR'), 0, 'nu nog niet');
const later = new Date(Date.now() + 72 * 3600 * 1000).toISOString();
assert.equal(positionsByAsset(later).find(p => p.symbol === 'EUR').net, 999_999, 'straks wel');
});
it('houdt ook een boeking met een datum in de toekomst buiten het totaal van nu', () => {
event({ assetId: eur, to: bank, amount: 10_000, at: gisteren });
event({ assetId: eur, to: bank, amount: 500_000, at: morgen });
assert.equal(net('EUR'), 10_000);
assert.deepEqual(accountPositions(bank), [{ assetId: eur, saldo: 10_000 }]);
});
it('laat de assets zonder boeking binnen het venster gewoon in de lijst staan', () => {
event({ assetId: eur, to: bank, amount: 500_000, at: morgen });
assert.deepEqual(positionsByAsset().map(p => p.symbol), ['BTC', 'EUR'], 'beide assets staan er nog');
assert.equal(net('EUR'), 0);
});
});
describe('lightningBalanceDiffs en recordLightningBalance', () => {
const GEMETEN = '2026-10-19T10:00:00.000Z';
function bron(kind, sats, acc = kluis) {
let conn = null;
if (kind === 'lnd') {
run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt)
VALUES ('lnd', ?, ?, 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`, [`Node ${acc}`, `node ${acc}`]);
conn = lastId();
}
run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, balanceSats, lastScannedAt, createdAt)
VALUES (?, ?, ?, ?, ?, ?, '2026-01-01')`, [acc, conn, kind, `${kind}-${acc}`, sats, GEMETEN]);
}
function adres(sats, acc = kluis) {
run(`INSERT INTO WalletAddress (accountId, address, balanceSats, createdAt) VALUES (?, ?, ?, '2026-01-01')`,
[acc, `bc1q-${acc}-${sats}`, sats]);
}
it('lightningBalances: per account de node, en NWC alleen zonder node', () => {
bron('lnd', 70_000);
bron('nwc', 21_000);
bron('nwc', 5_000, bank);
assert.deepEqual(lightningBalances().map(r => [r.accountId, r.sats, r.scannedAt]),
[[kluis, 70_000, GEMETEN], [bank, 5_000, GEMETEN]]);
});
it('meet on-chain plus de node tegen het grootboek, zonder het NWC-saldo erbovenop', () => {
bron('lnd', 70_000);
bron('nwc', 21_000);
adres(30_000);
event({ to: kluis, amount: 90_000, at: '2026-10-01T00:00:00.000Z' });
assert.deepEqual(lightningBalanceDiffs().map(d => [d.accountId, d.measured, d.ledger, d.gap]),
[[kluis, 100_000, 90_000, 10_000]]);
});
it('een NWC-wallet zonder LND-node telt wel, en een gelijk grootboek geeft geen rij', () => {
bron('nwc', 21_000, bank);
assert.deepEqual(lightningBalanceDiffs().map(d => [d.accountId, d.measured]), [[bank, 21_000]]);
event({ to: bank, amount: 21_000, at: '2026-10-01T00:00:00.000Z' });
assert.deepEqual(lightningBalanceDiffs(), []);
});
it('vastleggen maakt een balans met het GEMETEN bedrag, waarna de rij verdwijnt', async () => {
bron('lnd', 70_000);
const id = await recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000, balance: 1 });
const v = harness.exports.one(`SELECT * FROM Valuation WHERE id = ?`, [id]);
assert.deepEqual([v.accountId, v.assetId, v.balance, v.rateAt, v.source], [kluis, btc, 70_000, GEMETEN, 'onchain']);
assert.deepEqual(lightningBalanceDiffs(), []);
});
it('vervangt een eerdere meting van dezelfde dag, maar nooit een getypte balans', async () => {
bron('lnd', 70_000);
const eerste = await recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000 });
run(`UPDATE WalletSource SET balanceSats = 71000`);
assert.equal(await recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000 }), eerste);
assert.equal(harness.exports.one(`SELECT balance FROM Valuation WHERE id = ?`, [eerste]).balance, 71_000);
run(`UPDATE Valuation SET source = 'manual'`);
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 60_000 }),
e => e.key === 'valuations.lightningSameDay');
});
it('weigert een account zonder bijgewerkte lightning-bron, een dag zonder vorm en een koers van nul', async () => {
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 1 }), /no updated lightning source/);
bron('lnd', 70_000);
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '19-10-2026', fiatRate: 1 }), /needs its day/);
await assert.rejects(recordLightningBalance(kluis, { valuedOn: '2026-10-19', fiatRate: 0 }), /rate above zero/);
});
});
+169
View File
@@ -0,0 +1,169 @@
// Generated, do not edit. sha256:72e7504c7f13026263eeeda6dcafb41dafbff8bea070ad9a3fc74a1411bd4d43
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDbHarness } from '../helpers/db-node.mjs';
import { PREFERENCES, ENUMS, CONFIG } from '../../shared/config.js';
process.env.ASSETAPP_DATA_DIR = mkdtempSync(join(tmpdir(), 'assetapp-prefs-'));
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { getPref, getSettings, setPref, updateSettings } = await import('../../server/data/preferences.js');
beforeEach(() => harness.recreate());
const ruweWaarde = (scope, key) => {
const r = harness.exports.one(`SELECT value FROM Preference WHERE scope=? AND key=?`, [scope, key]);
return r ? r.value : null;
};
describe('setPref: validatie tegen de registry', () => {
it('accepteert elke toegestane enum-waarde', async () => {
for (const thema of ENUMS.THEME) {
await setPref('app', 'theme', thema);
assert.equal(getPref('app', 'theme'), thema);
}
});
it('weigert een enum-waarde buiten de registry', async () => {
await assert.rejects(() => setPref('app', 'theme', 'neon'), /theme/);
assert.equal(getPref('app', 'theme'), PREFERENCES.app.theme.initial,
'de oude waarde blijft staan');
});
it('maakt van een bool een 0 of 1', async () => {
await setPref('app', 'blur', true);
assert.equal(getPref('app', 'blur'), 1, 'true wordt 1');
await setPref('app', 'blur', 0);
assert.equal(getPref('app', 'blur'), 0);
});
it('weigert een bool-waarde die geen 0 of 1 oplevert', async () => {
await assert.rejects(() => setPref('app', 'blur', 2), /blur/);
await assert.rejects(() => setPref('app', 'blur', 'ja'), /blur/,
'niet-numerieke tekst levert NaN op en hoort geweigerd te worden');
});
it('eist bij een int een geheel getal binnen min/max', async () => {
const spec = PREFERENCES.app.auto_blur_min;
await setPref('app', 'auto_blur_min', spec.min);
assert.equal(getPref('app', 'auto_blur_min'), spec.min);
await assert.rejects(() => setPref('app', 'auto_blur_min', 2.5), (e) => e.key === 'settings.prefWantsInteger');
await assert.rejects(() => setPref('app', 'auto_blur_min', spec.min - 1), (e) => e.key === 'settings.prefBelowMin');
await assert.rejects(() => setPref('app', 'auto_blur_min', spec.max + 1), (e) => e.key === 'settings.prefAboveMax');
});
it('laat een string ongemoeid', async () => {
await setPref('app', 'last_backup_at', '2026-10-27T10:00:00Z');
assert.equal(getPref('app', 'last_backup_at'), '2026-10-27T10:00:00Z');
});
it('bewaart een scope zonder registry-regel as-is', async () => {
const view = { sort: { key: 'occurredAt', dir: 'desc' }, group: null };
await setPref('table.trades', 'view', view);
assert.deepEqual(getPref('table.trades', 'view'), view);
});
});
describe('getPref: terugval en coercie', () => {
it('geeft de initiële waarde uit de registry als er geen rij is', () => {
harness.exports.run(`DELETE FROM Preference`);
assert.equal(getPref('app', 'theme'), PREFERENCES.app.theme.initial);
});
it('geeft de meegegeven fallback bij een onbekende sleutel', () => {
assert.equal(getPref('bestaat', 'niet', 'terugval'), 'terugval');
assert.equal(getPref('bestaat', 'niet'), undefined, 'zonder fallback: undefined');
});
it('coërceert een opgeslagen enum-waarde die niet meer bestaat', () => {
assert.ok(!ENUMS.ACCENT.includes('paars'));
harness.exports.run(
`INSERT INTO Preference (scope,key,value) VALUES ('app','accent','"paars"')
ON CONFLICT(scope,key) DO UPDATE SET value = excluded.value`);
assert.equal(getPref('app', 'accent'), PREFERENCES.app.accent.initial);
});
it('laat een geldige enum-waarde ongemoeid', () => {
const geldig = ENUMS.ACCENT.at(-1);
harness.exports.run(
`INSERT INTO Preference (scope,key,value) VALUES ('app','accent',?)
ON CONFLICT(scope,key) DO UPDATE SET value = excluded.value`, [JSON.stringify(geldig)]);
assert.equal(getPref('app', 'accent'), geldig);
});
});
describe('voorkeuren dragen geen geheimen meer', () => {
it('heeft nergens in de registry nog een secret-vlag', () => {
const metVlag = [];
for (const [scope, keys] of Object.entries(PREFERENCES)) {
for (const [key, spec] of Object.entries(keys)) {
if (spec.secret) metVlag.push(`${scope}.${key}`);
}
}
assert.deepEqual(metVlag, []);
});
it('kent de nwc-scope niet meer', () => {
assert.equal(PREFERENCES.nwc, undefined,
'de NWC-URI hoort server-side te staan (data/secrets.json, sleutel nwc_<bron-id>)');
});
it('slaat elke waarde op als leesbare JSON, ook onder een onbekende scope', async () => {
await setPref('view.demo', 'stand', { sort: 'naam' });
assert.deepEqual(JSON.parse(ruweWaarde('view.demo', 'stand')), { sort: 'naam' },
'een vrije scope wordt as-is bewaard; er is geen versleutel-pad meer dat dit onderschept');
});
});
describe('getSettings / updateSettings', () => {
it('levert elke app-voorkeur plus de schemaversie', () => {
const s = getSettings();
for (const key of Object.keys(PREFERENCES.app)) {
if (!PREFERENCES.app[key].secret) assert.ok(key in s, `${key} ontbreekt`);
}
assert.equal(s.version, CONFIG.SCHEMA_VERSION);
});
it('werkt meerdere instellingen in één keer bij', async () => {
await updateSettings({ theme: 'light', locale: 'nl' });
assert.equal(getPref('app', 'theme'), 'light');
assert.equal(getSettings().locale, 'nl');
});
it('bewaart de taal bij de installatie en niet in de portefeuille', async () => {
await updateSettings({ locale: 'en' });
assert.equal(ruweWaarde('app', 'locale'), null);
const bestand = join(process.env.ASSETAPP_DATA_DIR, CONFIG.INSTALLATION.SETTINGS_FILE_NAME);
assert.equal(JSON.parse(readFileSync(bestand, 'utf8')).locale, 'en');
harness.recreate();
assert.equal(getSettings().locale, 'en', 'een andere database verandert de taal niet');
});
it('weigert een taal zonder catalogus', async () => {
await updateSettings({ locale: 'nl' });
await assert.rejects(() => updateSettings({ locale: 'xx' }), /Unknown locale/);
assert.equal(getSettings().locale, 'nl');
});
it('slaat undefined-waarden over', async () => {
await updateSettings({ locale: 'nl' });
await updateSettings({ theme: 'light', locale: undefined });
assert.equal(getSettings().locale, 'nl');
});
it('weigert een onbekende instelling', async () => {
await assert.rejects(() => updateSettings({ verzonnen: 1 }), (e) => e.key === 'settings.unknownSetting');
});
it('valideert nog steeds per type', async () => {
await assert.rejects(() => updateSettings({ theme: 'neon' }), /theme/);
});
});
+90
View File
@@ -0,0 +1,90 @@
// Generated, do not edit. sha256:0be72af65d499e9d6366a58d1015da19ddfe0424554e9524fa644fdd737582f5
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const { priceSeriesExport } = await import('../../server/data/price-history.js');
const { priceHistoryCsv, CSV_COLUMNS } = await import('../../server/price-export.mjs');
const { run, lastId } = harness.exports;
let btc, goud, eur;
function provider(key, { system = false, dekt = [] } = {}) {
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
VALUES (?, ?, ?, '', 'none', 0, 1, 1, ?, '2026-10-09')`, [key, key, key, system ? 1 : 0]);
const id = lastId();
for (const s of dekt) run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, ?)`, [id, s]);
return id;
}
function asset(symbol, kind) {
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES (?, ?, ?, ?, ?, NULL, 8, 'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]);
return lastId();
}
const koers = (assetId, providerId, prijs, observedAt, resolution = 'day', createdAt = null) =>
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)`,
[assetId, providerId, prijs, resolution, observedAt, createdAt]);
beforeEach(() => {
harness.recreate();
btc = asset('BTC', 'crypto');
goud = asset('XAU', 'metal');
eur = asset('EUR', 'fiat');
const bitvavo = provider('bitvavo', { dekt: ['BTC'] });
const goldapi = provider('goldapi', { dekt: ['XAU'] });
const manual = provider('manual', { system: true });
const dienst = provider('atreides_services', { system: true });
const vast = provider('fixed', { system: true, dekt: ['EUR'] });
koers(btc, bitvavo, 61000, '2026-10-02T00:00:00Z', 'day', '2026-10-03T01:00:00Z');
koers(btc, bitvavo, 60000, '2026-10-01T00:00:00Z');
koers(btc, bitvavo, 61500, '2026-10-02T14:32:00Z', 'timestamp');
koers(goud, goldapi, 105.5, '2026-10-01T00:00:00Z');
koers(btc, manual, 50000, '2026-09-30T00:00:00Z');
koers(btc, dienst, 61234.5, '2026-10-09T10:00:00Z', 'timestamp');
koers(eur, vast, 1, '2026-10-01T00:00:00Z');
koers(goud, bitvavo, 999, '2026-10-01T00:00:00Z');
});
describe('priceSeriesExport: wat meegaat', () => {
it('alleen de koersreeksen van echte bronnen, in een vaste volgorde', () => {
const rijen = priceSeriesExport().map(r => `${r.symbol}|${r.source}|${r.resolution}|${r.observedAt}`);
assert.deepEqual(rijen, [
'BTC|bitvavo|day|2026-10-01T00:00:00Z',
'BTC|bitvavo|day|2026-10-02T00:00:00Z',
'BTC|bitvavo|timestamp|2026-10-02T14:32:00Z',
'XAU|goldapi|day|2026-10-01T00:00:00Z',
]);
});
});
describe('priceHistoryCsv: het bestand', () => {
it('kolommen zoals de CSV van de dienst, met eenheid en resolutie erbij', () => {
const regels = priceHistoryCsv(priceSeriesExport(), 'EUR').trim().split('\n');
assert.equal(regels[0], CSV_COLUMNS.join(','));
assert.equal(regels[0], 'instrument,source,quote,unit,resolution,observed_at_utc,fetched_at_utc,price');
assert.equal(regels[2], 'crypto:BTC,bitvavo,EUR,,day,2026-10-02T00:00:00.000Z,2026-10-03T01:00:00.000Z,61000');
assert.equal(regels[4], 'metal:XAU,goldapi,EUR,g,day,2026-10-01T00:00:00.000Z,,105.5');
});
it('is herhaalbaar: twee keer exporteren geeft hetzelfde bestand', () => {
assert.equal(priceHistoryCsv(priceSeriesExport(), 'EUR'), priceHistoryCsv(priceSeriesExport(), 'EUR'));
});
it('noemt de rekenvaluta van de portefeuille, want daarin staat de historie', () => {
assert.match(priceHistoryCsv(priceSeriesExport(), 'USD').split('\n')[1], /^crypto:BTC,bitvavo,USD,/);
});
it('slaat een rij zonder bruikbare tijd of prijs over, en zet een lastige waarde tussen aanhalingstekens', () => {
const csv = priceHistoryCsv([
{ kind: 'crypto', symbol: 'BTC', source: 'a,b', resolution: 'day', observedAt: '2026-10-01', createdAt: null, fiatPrice: 1 },
{ kind: 'crypto', symbol: 'BTC', source: 'x', resolution: 'day', observedAt: 'geen datum', createdAt: null, fiatPrice: 1 },
{ kind: 'crypto', symbol: 'BTC', source: 'x', resolution: 'day', observedAt: '2026-10-01', createdAt: null, fiatPrice: 0 },
], 'EUR').trim().split('\n');
assert.equal(csv.length, 2);
assert.equal(csv[1], 'crypto:BTC,"a,b",EUR,,day,2026-10-01T00:00:00.000Z,,1');
});
});
+676
View File
@@ -0,0 +1,676 @@
// Generated, do not edit. sha256:f793f1998c617a8dfc8fce95de7c88d4b4d4d1a08ecaa3c19643f70bcb8ebab4
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { toSearchName } from '../../shared/search-name.js';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG, ENUMS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
addManualPricePoint, getPriceAt, getPricePoint, latestPricePoint, latestSpotPoint, listAllPriceHistory,
listPriceGaps, listPriceHistory, priceHistoryCount, recordCurrentSpot, upsertPriceHistory,
ignorePriceGap, listIgnoredPriceGaps, unignorePriceGap, recordNoPriceDays, listNoPriceDays,
listPriceAbsences, deletePriceAbsences,
} = await import('../../server/data/price-history.js');
const DREMPEL = CONFIG.PRICE_HISTORY.NEAREST_MAX_SECONDS;
let btc, eth;
beforeEach(() => {
harness.recreate();
providerIds.clear();
providerId('mempool'); providerId('bitvavo');
btc = asset('BTC');
eth = asset('ETH');
dekking('mempool', 'BTC', 'ETH');
dekking('bitvavo', 'BTC', 'ETH');
});
function asset(symbol) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,'crypto',?,NULL,8,'unit')`, [symbol, symbol, toSearchName(symbol), symbol]);
return harness.exports.lastId();
}
function provider(key) {
harness.exports.run(
`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES (?,?,?,'','none',0,1,1,0,'2024-01-01T00:00:00Z')`, [key, key, toSearchName(key)]);
return harness.exports.lastId();
}
const providerIds = new Map();
const providerId = key => {
if (!providerIds.has(key)) providerIds.set(key, provider(key));
return providerIds.get(key);
};
function dekking(key, ...symbolen) {
for (const s of symbolen)
harness.exports.run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [providerId(key), s]);
}
function systeemProvider(key) {
harness.exports.run(
`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
isActive, isSeed, isSystem, createdAt)
VALUES (?,?,?,'','none',0,1,1,1,'2024-01-01T00:00:00Z')`, [key, key, toSearchName(key)]);
providerIds.set(key, harness.exports.lastId());
return providerIds.get(key);
}
function punt({ assetId = null, observedAt, resolution = 'day', fiatPrice = 40000, source = 'mempool' }) {
harness.exports.run(
`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
VALUES (?,?,?,?,?,?)`, [assetId ?? btc, observedAt, resolution, fiatPrice, providerId(source), observedAt]);
return harness.exports.lastId();
}
const unix = iso => Math.floor(Date.parse(iso) / 1000);
const vanaf = (momentIso, sec) => new Date((unix(momentIso) + sec) * 1000).toISOString();
describe('upsertPriceHistory', () => {
it('valideert de resolutie tegen de enum', async () => {
assert.deepEqual([...ENUMS.PRICE_RESOLUTION], ['day', 'timestamp']);
await assert.rejects(
() => upsertPriceHistory(btc, [{ time: unix('2024-01-01T12:00:00Z'), price: 1 }], 'mempool', 'uurtje'),
/resolution/);
});
it('normaliseert een dag-punt naar middernacht UTC', async () => {
await upsertPriceHistory(btc, [{ time: unix('2024-03-05T14:37:11Z'), price: 1000 }], 'mempool', 'day');
assert.equal(harness.exports.one(`SELECT observedAt FROM PriceHistory`).observedAt,
'2024-03-05T00:00:00Z');
});
it('bewaart bij een timestamp-punt het exacte moment', async () => {
await upsertPriceHistory(btc, [{ time: unix('2024-03-05T14:37:11Z'), price: 1000 }], 'mempool', 'timestamp');
assert.equal(harness.exports.one(`SELECT observedAt FROM PriceHistory`).observedAt,
'2024-03-05T14:37:11.000Z');
});
it('werkt een bestaand punt bij in plaats van te dupliceren', async () => {
const t = unix('2024-03-05T00:00:00Z');
await upsertPriceHistory(btc, [{ time: t, price: 1000 }], 'mempool', 'day');
await upsertPriceHistory(btc, [{ time: t, price: 2000 }], 'mempool', 'day');
const rijen = harness.exports.all(`SELECT fiatPrice FROM PriceHistory`);
assert.equal(rijen.length, 1, 'ON CONFLICT werkt bij, dus geen tweede rij');
assert.equal(rijen[0].fiatPrice, 2000, 'de nieuwste waarde wint');
});
it('bewaart de bron achter een koers van de dienst, en werkt hem bij met de koers', async () => {
const t = unix('2024-03-05T00:00:00Z');
const bron = () => harness.exports.one(`SELECT sourceName FROM PriceHistory`).sourceName;
await upsertPriceHistory(btc, [{ time: t, price: 1000 }], 'mempool', 'day');
assert.equal(bron(), null, 'zonder bron blijft hij leeg');
await upsertPriceHistory(btc, [{ time: t, price: 1001, sourceName: 'Bitvavo' }], 'mempool', 'day');
assert.equal(bron(), 'Bitvavo');
await upsertPriceHistory(btc, [{ time: t, price: 1002, sourceName: 'ECB' }], 'mempool', 'day');
assert.equal(bron(), 'ECB', 'een nieuwere koers brengt zijn eigen bron mee');
});
it('zet createdAt bij het aanmaken en laat hem staan bij bijwerken', async () => {
const t = unix('2024-03-05T00:00:00Z');
await upsertPriceHistory(btc, [{ time: t, price: 1000 }], 'mempool', 'day');
const eerst = harness.exports.one(`SELECT createdAt FROM PriceHistory`).createdAt;
assert.ok(eerst, 'een nieuwe rij heeft een createdAt');
harness.exports.run(`UPDATE PriceHistory SET createdAt = '2024-03-06T08:00:00.000Z'`);
await upsertPriceHistory(btc, [{ time: t, price: 2000 }], 'mempool', 'day');
const r = harness.exports.one(`SELECT createdAt, fiatPrice FROM PriceHistory`);
assert.equal(r.fiatPrice, 2000);
assert.equal(r.createdAt, '2024-03-06T08:00:00.000Z', 'bijwerken raakt createdAt niet');
});
it('houdt reeksen van verschillende bronnen naast elkaar', async () => {
const t = unix('2024-03-05T00:00:00Z');
await upsertPriceHistory(btc, [{ time: t, price: 1000 }], 'mempool', 'day');
await upsertPriceHistory(btc, [{ time: t, price: 1010 }], 'bitvavo', 'day');
assert.equal(priceHistoryCount(btc), 2, 'de bron zit in de unieke sleutel');
});
it('slaat een punt zonder koers over in plaats van te weigeren', async () => {
await upsertPriceHistory(btc, [
{ time: unix('2024-03-05T00:00:00Z') },
{ time: unix('2024-03-06T00:00:00Z'), price: 1100 },
], 'mempool', 'day');
const rijen = harness.exports.all(`SELECT observedAt, fiatPrice FROM PriceHistory`);
assert.deepEqual(rijen.map(r => [r.observedAt, r.fiatPrice]), [['2024-03-06T00:00:00Z', 1100]]);
});
it('slaat een koers van nul of lager over', async () => {
await upsertPriceHistory(btc, [{ time: unix('2012-03-05T00:00:00Z'), price: 0 }], 'mempool', 'day');
await upsertPriceHistory(btc, [{ time: unix('2012-03-06T00:00:00Z'), price: 4 }], 'mempool', 'day');
const rijen = harness.exports.all(`SELECT fiatPrice FROM PriceHistory ORDER BY observedAt`);
assert.deepEqual(rijen.map(r => r.fiatPrice), [4]);
});
it('slaat een punt zonder positieve koers over, ook een lege string', async () => {
await upsertPriceHistory(btc, [
{ time: unix('2012-03-05T00:00:00Z'), price: 0 },
{ time: unix('2012-03-06T00:00:00Z'), price: '' },
{ time: unix('2012-03-07T00:00:00Z'), price: -3 },
], 'mempool', 'day');
assert.equal(priceHistoryCount(btc), 0);
});
it('laat een opgeslagen koers staan als dezelfde dag later met nul terugkomt', async () => {
const t = unix('2024-03-05T00:00:00Z');
await upsertPriceHistory(btc, [{ time: t, price: 1000 }], 'mempool', 'day');
await upsertPriceHistory(btc, [{ time: t, price: 0 }], 'mempool', 'day');
const r = harness.exports.one(`SELECT fiatPrice FROM PriceHistory`);
assert.equal(r.fiatPrice, 1000);
});
});
describe('listPriceHistory / priceHistoryCount / getPricePoint', () => {
it('sorteert nieuwste eerst en filtert op asset', () => {
punt({ observedAt: '2024-01-01T00:00:00Z' });
punt({ observedAt: '2024-03-01T00:00:00Z' });
punt({ assetId: eth, observedAt: '2024-02-01T00:00:00Z' });
assert.deepEqual(listPriceHistory(btc).map(r => r.observedAt),
['2024-03-01T00:00:00Z', '2024-01-01T00:00:00Z']);
});
it('filtert vanaf een datum, grens inbegrepen', () => {
punt({ observedAt: '2024-01-01T00:00:00Z' });
punt({ observedAt: '2024-02-01T00:00:00Z' });
assert.equal(listPriceHistory(btc, '2024-02-01').length, 1);
assert.equal(listPriceHistory(btc, '2024-01-01').length, 2, 'de begindag telt mee');
});
it('telt per asset', () => {
punt({ observedAt: '2024-01-01T00:00:00Z' });
punt({ assetId: eth, observedAt: '2024-01-01T00:00:00Z' });
assert.equal(priceHistoryCount(btc), 1);
assert.equal(priceHistoryCount(eth), 1);
});
it('getPricePoint vindt een dagpunt op datum', () => {
punt({ observedAt: '2024-01-01T00:00:00Z', fiatPrice: 42000 });
assert.equal(getPricePoint(btc, '2024-01-01').fiatPrice, 42000);
assert.equal(getPricePoint(btc, '2024-01-02'), null);
});
it('getPricePoint valt zonder dagpunt terug op de laatste spot van die dag, gemarkeerd', () => {
punt({ observedAt: '2024-01-01T09:00:00Z', resolution: 'timestamp', fiatPrice: 1 });
punt({ observedAt: '2024-01-01T21:00:00Z', resolution: 'timestamp', fiatPrice: 2 });
const p = getPricePoint(btc, '2024-01-01');
assert.equal(p.fiatPrice, 2, 'de LAATSTE spot van de dag');
assert.equal(p.derivedFrom, 'spot');
});
it('getPricePoint laat een echt dagpunt voorgaan, en geeft met derived:false alleen dat', () => {
punt({ observedAt: '2024-01-01T21:00:00Z', resolution: 'timestamp', fiatPrice: 2 });
assert.equal(getPricePoint(btc, '2024-01-01', { derived: false }), null);
punt({ observedAt: '2024-01-01T00:00:00Z', fiatPrice: 5 });
const p = getPricePoint(btc, '2024-01-01');
assert.equal(p.fiatPrice, 5);
assert.equal(p.derivedFrom, undefined);
});
it('getPricePoint neemt bij meerdere bronnen de laatst toegevoegde rij', () => {
punt({ observedAt: '2024-01-01T00:00:00Z', fiatPrice: 100, source: 'mempool' });
punt({ observedAt: '2024-01-01T00:00:00Z', fiatPrice: 200, source: 'bitvavo' });
assert.equal(getPricePoint(btc, '2024-01-01').fiatPrice, 200);
});
});
describe('getPriceAt', () => {
const MOMENT = '2024-06-15T12:00:00Z';
it('geeft null zonder enig punt', () => {
assert.equal(getPriceAt(btc, unix(MOMENT)), null);
});
it('kiest een timestamp-punt binnen de drempel', () => {
punt({ observedAt: vanaf(MOMENT, -30), resolution: 'timestamp', fiatPrice: 111 });
const r = getPriceAt(btc, unix(MOMENT));
assert.equal(r.fiatPrice, 111);
assert.equal(r.resolution, 'timestamp');
assert.equal(r.dist_seconds, 30, 'de afstand wordt meegegeven');
});
it('neemt een timestamp-punt buiten de drempel niet als moment-koers, wel als koers van die dag', () => {
punt({ observedAt: new Date((unix(MOMENT) - DREMPEL - 60) * 1000).toISOString(), resolution: 'timestamp', fiatPrice: 111 });
const p = getPriceAt(btc, unix(MOMENT));
assert.equal(p.fiatPrice, 111);
assert.equal(p.derivedFrom, 'spot');
assert.equal(p.dist_seconds, undefined, 'geen moment-treffer, dus geen afstand');
});
it('geeft niets als er op die dag geen enkele koers is', () => {
punt({ observedAt: '2020-01-01T12:00:00Z', resolution: 'timestamp', fiatPrice: 111 });
assert.equal(getPriceAt(btc, unix(MOMENT)), null);
});
it('kiest van twee timestamp-punten het naaste, ook als dat later ligt', () => {
punt({ observedAt: vanaf(MOMENT, -Math.floor(DREMPEL * 0.8)), resolution: 'timestamp', fiatPrice: 100 });
punt({ observedAt: vanaf(MOMENT, Math.floor(DREMPEL * 0.3)), resolution: 'timestamp', fiatPrice: 200 });
assert.equal(getPriceAt(btc, unix(MOMENT)).fiatPrice, 200, 'dichterbij weegt zwaarder dan eerder');
});
it('valt terug op een dagpunt van dezelfde kalenderdag', () => {
punt({ observedAt: '2024-06-15T00:00:00Z', fiatPrice: 300 });
const r = getPriceAt(btc, unix(MOMENT));
assert.equal(r.fiatPrice, 300);
assert.equal(r.resolution, 'day');
assert.equal(r.dist_seconds, null, 'een dagpunt heeft geen afstand');
});
it('gebruikt geen dagpunt van een andere dag', () => {
punt({ observedAt: '2024-06-14T00:00:00Z', fiatPrice: 300 });
assert.equal(getPriceAt(btc, unix(MOMENT)), null);
});
it('geeft een timestamp-punt voorrang boven een dagpunt', () => {
punt({ observedAt: '2024-06-15T00:00:00Z', fiatPrice: 300 });
punt({ observedAt: vanaf(MOMENT, -30), resolution: 'timestamp', fiatPrice: 111 });
assert.equal(getPriceAt(btc, unix(MOMENT)).fiatPrice, 111);
});
describe('voorkeursbron', () => {
it('kiest de voorkeursbron ook als een andere bron naderbij ligt', () => {
punt({ observedAt: vanaf(MOMENT, Math.floor(DREMPEL * 0.3)), resolution: 'timestamp', fiatPrice: 100, source: 'mempool' });
punt({ observedAt: vanaf(MOMENT, -Math.floor(DREMPEL * 0.8)), resolution: 'timestamp', fiatPrice: 200, source: 'bitvavo' });
assert.equal(getPriceAt(btc, unix(MOMENT), 'bitvavo').fiatPrice, 200,
'de bron-matrix bepaalt welke provider leidend is');
});
it('valt binnen dezelfde resolutie terug op een andere bron', () => {
punt({ observedAt: vanaf(MOMENT, -30), resolution: 'timestamp', fiatPrice: 100, source: 'mempool' });
assert.equal(getPriceAt(btc, unix(MOMENT), 'bitvavo').fiatPrice, 100,
'liever een punt van een andere bron dan geen koers');
});
it('gaat niet van resolutie af om de voorkeursbron te volgen', () => {
punt({ observedAt: vanaf(MOMENT, -30), resolution: 'timestamp', fiatPrice: 100, source: 'mempool' });
punt({ observedAt: '2024-06-15T00:00:00Z', fiatPrice: 999, source: 'bitvavo' });
assert.equal(getPriceAt(btc, unix(MOMENT), 'bitvavo').fiatPrice, 100);
});
});
});
describe('latestPricePoint', () => {
it('geeft het nieuwste punt, ongeacht de resolutie', () => {
punt({ observedAt: '2026-01-01T00:00:00Z', fiatPrice: 100 });
punt({ observedAt: '2026-03-01T12:00:00Z', resolution: 'timestamp', fiatPrice: 300 });
punt({ observedAt: '2026-02-01T00:00:00Z', fiatPrice: 200 });
assert.equal(latestPricePoint(btc).fiatPrice, 300);
});
it('geeft null als er niets staat, en niet nul', () => {
assert.equal(latestPricePoint(eth), null);
});
it('kijkt niet over de asset-grens heen', () => {
punt({ assetId: eth, observedAt: '2027-01-01T00:00:00Z', fiatPrice: 999 });
punt({ observedAt: '2026-01-01T00:00:00Z', fiatPrice: 100 });
assert.equal(latestPricePoint(btc).fiatPrice, 100, 'het nieuwere punt van een ANDER asset hoort niet mee');
});
it('kiest bij twee punten op hetzelfde moment de laatst geschreven rij', () => {
punt({ observedAt: '2026-01-01T00:00:00Z', fiatPrice: 100, source: 'mempool' });
punt({ observedAt: '2026-01-01T00:00:00Z', fiatPrice: 111, source: 'bitvavo' });
assert.equal(latestPricePoint(btc).fiatPrice, 111);
});
});
describe('latestSpotPoint', () => {
it('geeft de nieuwste spot van die ene bron', () => {
punt({ observedAt: '2026-09-25T08:00:00.000Z', resolution: 'timestamp', fiatPrice: 1 });
punt({ observedAt: '2026-09-25T09:00:00.000Z', resolution: 'timestamp', fiatPrice: 2 });
assert.equal(latestSpotPoint(btc, 'mempool').fiatPrice, 2);
});
it('neemt de spot van een andere bron niet, ook niet als die nieuwer is', () => {
punt({ observedAt: '2026-09-25T08:00:00.000Z', resolution: 'timestamp', fiatPrice: 1 });
punt({ observedAt: '2026-09-25T09:00:00.000Z', resolution: 'timestamp', fiatPrice: 2, source: 'bitvavo' });
assert.equal(latestSpotPoint(btc, 'mempool').fiatPrice, 1);
});
it('neemt geen dagrij, geen ander asset en geen koers van nul', () => {
punt({ observedAt: '2026-09-25T00:00:00Z', fiatPrice: 5 });
punt({ assetId: eth, observedAt: '2026-09-25T09:00:00.000Z', resolution: 'timestamp', fiatPrice: 6 });
punt({ observedAt: '2026-09-25T10:00:00.000Z', resolution: 'timestamp', fiatPrice: 0 });
assert.equal(latestSpotPoint(btc, 'mempool'), null);
});
it('geeft null bij een onbekende bron in plaats van te gooien', () => {
assert.equal(latestSpotPoint(btc, 'bestaatniet'), null);
});
});
describe('listAllPriceHistory', () => {
beforeEach(() => {
punt({ observedAt: '2024-01-01T00:00:00Z', fiatPrice: 1 });
punt({ observedAt: '2024-03-01T00:00:00Z', fiatPrice: 2 });
punt({ assetId: eth, observedAt: '2024-02-01T00:00:00Z', fiatPrice: 3 });
});
it('geeft zonder filter alles, nieuwste eerst, met assetsymbool', () => {
const rijen = listAllPriceHistory();
assert.equal(rijen.length, 3);
assert.deepEqual(rijen.map(r => r.fiatPrice), [2, 3, 1]);
assert.equal(rijen[0].asset_symbol, 'BTC');
});
it('filtert op asset', () => {
assert.deepEqual(listAllPriceHistory({ assetId: eth }).map(r => r.fiatPrice), [3]);
});
it('filtert vanaf een datum', () => {
assert.deepEqual(listAllPriceHistory({ fromDate: '2024-02-01' }).map(r => r.fiatPrice), [2, 3]);
});
it('combineert beide filters', () => {
assert.deepEqual(listAllPriceHistory({ assetId: btc, fromDate: '2024-02-01' }).map(r => r.fiatPrice), [2]);
});
});
describe('dekkingsguard', () => {
const vreemdeRij = (extra = {}) => {
harness.exports.run(`DELETE FROM ProviderCoverage WHERE providerId = ? AND symbol = 'ETH'`,
[providerId('mempool')]);
return punt({ assetId: eth, observedAt: '2024-06-15T00:00:00Z', fiatPrice: 74500, source: 'mempool', ...extra });
};
it('laat een rij van een niet-dekkende bron geen dagpunt worden', () => {
vreemdeRij();
assert.equal(getPricePoint(eth, '2024-06-15'), null,
'mempool dekt geen ETH, dus deze rij is geen ETH-koers');
});
it('laat zo n rij ook getPriceAt niet winnen, op geen van beide resoluties', () => {
vreemdeRij();
vreemdeRij({ observedAt: '2024-06-15T12:00:00Z', resolution: 'timestamp' });
assert.equal(getPriceAt(eth, unix('2024-06-15T12:00:00Z')), null);
});
it('houdt hem uit listPriceHistory, en daarmee uit het daggrid van signalen en de spot-widget', () => {
vreemdeRij();
assert.deepEqual(listPriceHistory(eth), []);
assert.deepEqual(listPriceHistory(eth, '2024-01-01'), [], 'ook met een vanaf-datum');
});
it('houdt hem uit latestPricePoint', () => {
vreemdeRij();
assert.equal(latestPricePoint(eth), null);
});
it('laat een dekkende bron er wél doorheen, ook als de niet-dekkende nieuwer is', () => {
vreemdeRij({ observedAt: '2024-06-16T00:00:00Z' });
punt({ assetId: eth, observedAt: '2024-06-15T00:00:00Z', fiatPrice: 3000, source: 'bitvavo' });
assert.equal(latestPricePoint(eth).fiatPrice, 3000, 'de nieuwere rij van de niet-dekkende bron telt niet');
});
it('LAAT EEN HANDMATIG PUNT STAAN, ook al heeft de systeembron geen enkele dekkingsrij', () => {
systeemProvider('manual');
punt({ assetId: eth, observedAt: '2024-06-15T00:00:00Z', fiatPrice: 3000, source: 'manual' });
assert.equal(getPricePoint(eth, '2024-06-15').fiatPrice, 3000);
assert.equal(latestPricePoint(eth).fiatPrice, 3000);
assert.equal(listPriceHistory(eth).length, 1);
assert.equal(getPriceAt(eth, unix('2024-06-15T12:00:00Z')).fiatPrice, 3000);
});
it('toont de foute rij wél in de beheertabel, want daar wordt hij verwijderd', () => {
vreemdeRij();
assert.deepEqual(listAllPriceHistory().map(r => r.fiatPrice), [74500]);
assert.equal(priceHistoryCount(eth), 1, 'de telling gaat over wat er staat, niet over wat meetelt');
});
});
describe('listPriceGaps', () => {
const VANDAAG = '2026-09-24';
const boeking = (assetId, dag) => harness.exports.run(
`INSERT INTO Event (assetId, amount, type, source, occurredAt, createdAt)
VALUES (?, 100, 'transfer', 'manual', ?, ?)`, [assetId, `${dag}T10:00:00Z`, `${dag}T10:00:00Z`]);
const dagen = (assetId, van, tot, source = 'mempool') => {
for (let d = van; d <= tot; d = new Date(Date.parse(`${d}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)) {
punt({ assetId, observedAt: `${d}T00:00:00Z`, source });
}
};
it('vindt een gat in het midden, met de juiste eerste en laatste dag', () => {
boeking(btc, '2026-09-01');
dagen(btc, '2026-09-01', '2026-09-05');
dagen(btc, '2026-09-15', '2026-09-23');
assert.deepEqual(listPriceGaps({ today: VANDAAG }).rows,
[{ assetId: btc, symbol: 'BTC', from: '2026-09-06', until: '2026-09-14', days: 9 }]);
});
it('vindt de kop (koersen beginnen na de eerste boeking) en de staart (tot en met gisteren)', () => {
boeking(btc, '2026-08-01');
dagen(btc, '2026-09-01', '2026-09-10');
assert.deepEqual(listPriceGaps({ today: VANDAAG }).rows.map(g => [g.from, g.until, g.days]),
[['2026-08-01', '2026-08-31', 31], ['2026-09-11', '2026-09-23', 13]]);
});
it('telt een gat tot de drempel niet, zoals een weekend met een feestdag', () => {
boeking(btc, '2026-09-01');
dagen(btc, '2026-09-01', '2026-09-10');
dagen(btc, '2026-09-17', '2026-09-23');
assert.equal(listPriceGaps({ today: VANDAAG }).total, 0);
});
it('laat een bron zonder dekking geen gat dichten', () => {
harness.exports.run(`DELETE FROM ProviderCoverage WHERE providerId = ? AND symbol = 'BTC'`, [providerId('bitvavo')]);
boeking(btc, '2026-09-01');
dagen(btc, '2026-09-01', '2026-09-05');
dagen(btc, '2026-09-06', '2026-09-14', 'bitvavo');
dagen(btc, '2026-09-15', '2026-09-23');
assert.equal(listPriceGaps({ today: VANDAAG }).rows[0].from, '2026-09-06');
});
describe('een gat negeren', () => {
it('laat een genegeerd gat weg, en terugzetten brengt het terug', async () => {
boeking(btc, '2026-09-01');
dagen(btc, '2026-09-01', '2026-09-05');
dagen(btc, '2026-09-15', '2026-09-23');
const id = await ignorePriceGap({ assetId: btc, from: '2026-09-06', until: '2026-09-14' });
assert.equal(listPriceGaps({ today: VANDAAG }).total, 0);
assert.equal(listIgnoredPriceGaps()[0].symbol, 'BTC');
await unignorePriceGap(id);
assert.equal(listPriceGaps({ today: VANDAAG }).total, 1);
});
it('toont bij een staart die doorgroeit alleen het nieuwe stuk, en pas als dat de drempel haalt', async () => {
boeking(btc, '2026-09-01');
dagen(btc, '2026-09-01', '2026-09-05');
await ignorePriceGap({ assetId: btc, from: '2026-09-06', until: '2026-09-20' });
assert.equal(listPriceGaps({ today: '2026-09-24' }).total, 0, 'drie nieuwe dagen haalt de drempel niet');
assert.deepEqual(listPriceGaps({ today: '2026-10-01' }).rows.map(g => [g.from, g.until, g.days]),
[['2026-09-21', '2026-09-30', 10]], 'tien nieuwe dagen wel, en alleen die');
});
it('weigert een onbekend asset en een bereik dat geen bereik is', async () => {
await assert.rejects(() => ignorePriceGap({ assetId: 9999, from: '2026-09-01', until: '2026-09-02' }), /does not exist/);
await assert.rejects(() => ignorePriceGap({ assetId: btc, from: '2026-09-05', until: '2026-09-01' }), /not a range/);
await assert.rejects(() => ignorePriceGap({ assetId: btc, from: 'gisteren', until: '2026-09-01' }), /not a range/);
assert.deepEqual(listIgnoredPriceGaps(), []);
});
});
describe('dagen zonder koers per bron', () => {
it('onthoudt een dag één keer per bron, en levert hem vanaf een startdag terug', async () => {
await recordNoPriceDays(btc, 'mempool', ['2025-12-25', '2025-12-26']);
await recordNoPriceDays(btc, 'mempool', ['2025-12-26']);
await recordNoPriceDays(btc, 'bitvavo', ['2025-12-26']);
assert.deepEqual(listNoPriceDays(btc).map(r => r.day), ['2025-12-25', '2025-12-26', '2025-12-26'],
'twee bronnen op dezelfde dag zijn twee rijen, dezelfde bron twee keer is er één');
assert.deepEqual(listNoPriceDays(btc, '2025-12-26'),
[{ providerId: providerId('mempool'), day: '2025-12-26' }, { providerId: providerId('bitvavo'), day: '2025-12-26' }]
.sort((a, b) => a.providerId - b.providerId));
assert.deepEqual(listNoPriceDays(eth), [], 'per asset');
});
it('weigert een onbekend asset, een onbekende bron en iets wat geen dag is', async () => {
await assert.rejects(() => recordNoPriceDays(9999, 'mempool', ['2025-12-25']), /does not exist/);
await assert.rejects(() => recordNoPriceDays(btc, 'bestaat-niet', ['2025-12-25']), /Unknown price source/);
await assert.rejects(() => recordNoPriceDays(btc, 'mempool', ['25-12-2025']), /Not a list of days/);
await assert.rejects(() => recordNoPriceDays(btc, 'mempool', '2025-12-25'), /Not a list of days/);
assert.deepEqual(listNoPriceDays(btc), []);
});
it('voegt opeenvolgende dagen per asset en bron samen tot een periode', async () => {
await recordNoPriceDays(btc, 'mempool', ['2025-12-24', '2025-12-25', '2025-12-26', '2026-01-01']);
await recordNoPriceDays(btc, 'bitvavo', ['2025-12-25']);
await recordNoPriceDays(eth, 'mempool', ['2025-12-26']);
const r = listPriceAbsences().map(a => [a.assetId, a.providerName, a.fromDay, a.untilDay, a.days]);
assert.deepEqual(r, [
[btc, 'bitvavo', '2025-12-25', '2025-12-25', 1],
[btc, 'mempool', '2025-12-24', '2025-12-26', 3],
[btc, 'mempool', '2026-01-01', '2026-01-01', 1],
[eth, 'mempool', '2025-12-26', '2025-12-26', 1],
]);
});
it('vergeet alleen de periode van die bron, en weigert iets wat geen periode is', async () => {
await recordNoPriceDays(btc, 'mempool', ['2025-12-24', '2025-12-25', '2026-01-01']);
await recordNoPriceDays(btc, 'bitvavo', ['2025-12-25']);
await deletePriceAbsences({ assetId: btc, providerId: providerId('mempool'), from: '2025-12-24', until: '2025-12-25' });
assert.deepEqual(listNoPriceDays(btc).map(r => [r.providerId, r.day]),
[[providerId('bitvavo'), '2025-12-25'], [providerId('mempool'), '2026-01-01']]);
await assert.rejects(() => deletePriceAbsences({ assetId: btc, providerId: providerId('mempool'), from: '2026-01-02', until: '2026-01-01' }), /not a range/);
await assert.rejects(() => deletePriceAbsences({ assetId: btc, from: '2026-01-01', until: '2026-01-01' }), /required/);
assert.equal(listNoPriceDays(btc).length, 2, 'een geweigerde aanroep wist niets');
});
});
it('negeert een gat vóór de eerste boeking, een asset zonder boeking, en fiat', () => {
dagen(btc, '2026-06-01', '2026-06-02');
boeking(btc, '2026-09-01');
dagen(btc, '2026-09-01', '2026-09-23');
dagen(eth, '2026-09-01', '2026-09-02');
harness.exports.run(`UPDATE Asset SET kind = 'fiat' WHERE id = ?`, [eth]);
boeking(eth, '2026-01-01');
assert.equal(listPriceGaps({ today: VANDAAG }).total, 0);
});
});
describe('recordCurrentSpot', () => {
it('bewaart de spot als timestamp-punt', async () => {
await recordCurrentSpot(btc, 'mempool', { price: 55000 });
const r = harness.exports.one(`SELECT resolution, fiatPrice FROM PriceHistory`);
assert.equal(r.resolution, 'timestamp');
assert.equal(r.fiatPrice, 55000);
});
it('houdt één punt per asset+bron per dag en werkt dat BIJ', async () => {
await recordCurrentSpot(btc, 'mempool', { price: 55000 });
await recordCurrentSpot(btc, 'mempool', { price: 56000 });
assert.equal(priceHistoryCount(btc), 1, 'nog steeds één rij');
assert.equal(harness.exports.one(`SELECT fiatPrice FROM PriceHistory`).fiatPrice, 56000,
'maar met de LAATST geziene koers, want een slotkoers staat pas vast als de dag om is');
});
it('schuift het moment mee met de koers', async () => {
await recordCurrentSpot(btc, 'mempool', { price: 55000 });
const eerst = harness.exports.one(`SELECT observedAt FROM PriceHistory`).observedAt;
await recordCurrentSpot(btc, 'mempool', { price: 56000 });
const daarna = harness.exports.one(`SELECT observedAt FROM PriceHistory`).observedAt;
assert.ok(daarna >= eerst, 'het moment loopt niet terug');
assert.equal(daarna.slice(0, 10), new Date().toISOString().slice(0, 10), 'en blijft op vandaag');
});
it('bewaart wél een punt per bron', async () => {
await recordCurrentSpot(btc, 'mempool', { price: 55000 });
await recordCurrentSpot(btc, 'bitvavo', { price: 55100 });
assert.equal(priceHistoryCount(btc), 2);
});
it('doet niets zonder bruikbare koers', async () => {
await recordCurrentSpot(btc, 'mempool', null);
await recordCurrentSpot(btc, 'mempool', {});
await recordCurrentSpot(btc, 'mempool', { price: NaN });
await recordCurrentSpot(btc, 'mempool', { price: Infinity });
assert.equal(priceHistoryCount(btc), 0);
});
});
describe('addManualPricePoint', () => {
const morgen = () => new Date(Date.now() + 36 * 3600 * 1000).toISOString().slice(0, 10);
beforeEach(() => { systeemProvider('manual'); });
it('bewaart een dagpunt op de systeembron Handmatig', async () => {
const id = await addManualPricePoint({ assetId: btc, observedAt: '1994-06-15', fiatPrice: 315.5 });
assert.ok(id, 'het id komt terug, zodat de pagina de nieuwe rij kan aanwijzen');
const r = harness.exports.one(`SELECT ph.*, p.key AS bron FROM PriceHistory ph
JOIN Provider p ON p.id = ph.providerId WHERE ph.id = ?`, [id]);
assert.equal(r.fiatPrice, 315.5);
assert.equal(r.resolution, 'day');
assert.equal(r.observedAt, '1994-06-15T00:00:00Z', 'een dagpunt staat op middernacht UTC');
assert.equal(r.bron, 'manual', 'geen nieuwe systeembron: deze betekent al "met de hand"');
});
it('weigert een moment in de TOEKOMST', async () => {
await assert.rejects(() => addManualPricePoint({ assetId: btc, observedAt: morgen(), fiatPrice: 100 }),
/future/i);
assert.equal(priceHistoryCount(btc), 0);
});
it('weigert een koers van nul of lager', async () => {
await assert.rejects(() => addManualPricePoint({ assetId: btc, observedAt: '2024-01-01', fiatPrice: 0 }), /above zero/);
await assert.rejects(() => addManualPricePoint({ assetId: btc, observedAt: '2024-01-01', fiatPrice: -5 }), /above zero/);
await assert.rejects(() => addManualPricePoint({ assetId: btc, observedAt: '2024-01-01', fiatPrice: 'veel' }), /above zero/);
assert.equal(priceHistoryCount(btc), 0);
});
it('weigert een onleesbare datum en een onbekend asset', async () => {
await assert.rejects(() => addManualPricePoint({ assetId: btc, observedAt: 'ooit', fiatPrice: 100 }), /valid moment/);
await assert.rejects(() => addManualPricePoint({ assetId: 99999, observedAt: '2024-01-01', fiatPrice: 100 }), /does not exist/);
assert.equal(priceHistoryCount(btc), 0);
});
it('overschrijft hetzelfde punt in plaats van er een tweede naast te zetten', async () => {
const a = await addManualPricePoint({ assetId: btc, observedAt: '2024-01-01', fiatPrice: 100 });
const b = await addManualPricePoint({ assetId: btc, observedAt: '2024-01-01', fiatPrice: 120 });
assert.equal(a, b, 'dezelfde rij');
assert.equal(priceHistoryCount(btc), 1);
assert.equal(harness.exports.one(`SELECT fiatPrice FROM PriceHistory`).fiatPrice, 120);
});
it('weigert een resolutie die niet bestaat', async () => {
await assert.rejects(() => addManualPricePoint({ assetId: btc, observedAt: '2024-01-01', fiatPrice: 100, resolution: 'uur' }));
});
});
describe('addManualPricePoint: de eenheid', () => {
let goud;
beforeEach(() => {
systeemProvider('manual');
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
VALUES ('XAU','Goud','goud','metal','ozt','g',4,'unit')`);
goud = harness.exports.lastId();
});
it('rekent een ouncerprijs om naar de gramprijs van de opslag', async () => {
const id = await addManualPricePoint({ assetId: goud, observedAt: '1994-06-15', fiatPrice: 315.5, unit: 'ozt' });
const r = harness.exports.one(`SELECT fiatPrice FROM PriceHistory WHERE id = ?`, [id]);
assert.equal(r.fiatPrice, 315.5 / CONFIG.METALS.UNITS.ozt);
assert.ok(r.fiatPrice > 10 && r.fiatPrice < 11, 'ruim tien euro per gram, en niet ruim driehonderd');
});
it('laat een gramprijs staan zoals hij is', async () => {
const id = await addManualPricePoint({ assetId: goud, observedAt: '1994-06-15', fiatPrice: 10.14, unit: 'g' });
assert.equal(harness.exports.one(`SELECT fiatPrice FROM PriceHistory WHERE id = ?`, [id]).fiatPrice, 10.14);
});
it('neemt zonder eenheid de HOOFDEENHEID van het asset en niet de weergave-eenheid', async () => {
harness.exports.run(`UPDATE Asset SET displayUnit = 'subunit' WHERE id = ?`, [goud]);
const id = await addManualPricePoint({ assetId: goud, observedAt: '1994-06-15', fiatPrice: 315.5 });
assert.equal(harness.exports.one(`SELECT fiatPrice FROM PriceHistory WHERE id = ?`, [id]).fiatPrice,
315.5 / CONFIG.METALS.UNITS.ozt, 'ozt, ook al staat de weergave op gram');
});
it('laat CRYPTO ongemoeid, wat er ook als eenheid meekomt', async () => {
const id = await addManualPricePoint({ assetId: btc, observedAt: '2024-01-01', fiatPrice: 40000, unit: 'ozt' });
assert.equal(harness.exports.one(`SELECT fiatPrice FROM PriceHistory WHERE id = ?`, [id]).fiatPrice, 40000);
});
it('weigert een eenheid die de config niet kent', async () => {
await assert.rejects(
() => addManualPricePoint({ assetId: goud, observedAt: '2024-01-01', fiatPrice: 100, unit: 'pond' }),
/Unknown metal unit/);
});
});
+576
View File
@@ -0,0 +1,576 @@
// Generated, do not edit. sha256:eb712800d03552fa4079bd778c098f3fc2fb75b87a3722d0d27803f76118c2b9
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
deleteProposal, ignoreAllPendingProposals, ignoreProposal, listPendingProposals,
resolveProposalAsInterest, resolveProposalAsInternal, resolveProposalAsTrade, resolveProposalAsTransfer,
revertProposal, syncTxProposals
} = await import('../../server/data/proposals.js');
const { linkTxEvent } = await import('../../server/data/tx-links.js');
const { eventsNetByAccount } = await import('../../server/data/events.js');
let ctx;
beforeEach(() => {
harness.recreate();
ctx = {
btc: asset('BTC'),
eigen: account('Hardware wallet', { ownCustody: true }),
beurs: account('Exchange', { ownCustody: false }),
};
});
function asset(symbol) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, [symbol, symbol, toSearchName(symbol), 'crypto', symbol, null, 8, 'unit']);
return harness.exports.lastId();
}
function account(naam, { ownCustody = true } = {}) {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), ownCustody ? 'wallet' : 'exchange', ownCustody ? 1 : 0, 1]);
return harness.exports.lastId();
}
function beweging({ account: acc = null, direction = 'in', valueSats = 250_000, txid = 'tx-1',
blockTime = '2024-06-01T10:00:00', feeSats = null } = {}) {
harness.exports.run(
`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, feeSats, address, blockHeight, blockTime, createdAt)
VALUES (?,'onchain',?,?,?,?,?,?,?,?)`,
[acc ?? ctx.eigen, txid, direction, valueSats, feeSats, null,
blockTime ? 840_000 : null, blockTime, '2024-06-02T00:00:00']);
return harness.exports.lastId();
}
const voorstellen = () => harness.exports.all(
`SELECT id, walletTransactionId, amount, direction, suggestedType, status, resolvedEventId
FROM ReconcileProposal ORDER BY id`);
const events = () => harness.exports.all(`SELECT * FROM Event ORDER BY id`);
describe('syncTxProposals', () => {
it('maakt één voorstel per beweging zonder koppeling', async () => {
beweging({ txid: 'a' });
beweging({ txid: 'b' });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 2);
assert.equal(voorstellen().length, 2);
assert.ok(voorstellen().every(p => p.status === 'pending'));
});
describe('lightning', () => {
let nodes = 0;
function bron(acc, kind) {
let connectionId = null;
if (kind === 'lnd') {
harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt)
VALUES ('lnd', ?, ?, 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`, [`Node ${++nodes}`, `node ${nodes}`]);
connectionId = harness.exports.lastId();
}
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, name, createdAt)
VALUES (?, ?, ?, ?, ?, '2026-01-01')`, [acc, connectionId, kind, `${kind}-${acc}`, kind === 'nwc' ? 'Zaps' : null]);
return harness.exports.lastId();
}
function betaling(acc, sourceId, txid = 'hash-zap', feeSats = 1) {
harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, feeSats, blockTime, createdAt)
VALUES (?, ?, 'lightning', ?, 'out', 21, ?, '2026-10-19T10:00:00Z', '2026-10-19')`, [acc, sourceId, txid, feeSats]);
return harness.exports.lastId();
}
it('één voorstel voor een betaling op LND en NWC: vanaf de node', async () => {
const lnd = betaling(ctx.eigen, bron(ctx.eigen, 'lnd'));
betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1);
assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [lnd]);
});
it('een NWC-wallet op een account zonder LND-bron krijgt wel een voorstel', async () => {
const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1);
assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [nwc]);
});
it('een LND-node op een ander account telt niet voor deze NWC-wallet', async () => {
bron(ctx.beurs, 'lnd');
const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
await syncTxProposals(ctx.eigen, ctx.btc);
assert.deepEqual(voorstellen().map(p => p.walletTransactionId), [nwc]);
});
it('de lijst zegt via welke NWC-wallet de betaling liep', async () => {
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'));
betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'), 'alleen-node');
await syncTxProposals(ctx.eigen, ctx.btc);
const rijen = listPendingProposals().map(p => [p.transactionHash, p.tx_layer, p.via_wallet]);
assert.deepEqual(rijen.sort(), [['alleen-node', 'lightning', null], ['hash-zap', 'lightning', 'Zaps']]);
});
it('neemt de memo van de NWC-tweeling mee, en zet de zap in de notitie van de boeking', async () => {
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'));
const nwc = betaling(ctx.eigen, bron(ctx.eigen, 'nwc'));
const zap = JSON.stringify({ kind: 9734, pubkey: 'c'.repeat(64), content: 'Dank!',
tags: [['p', '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d']] });
harness.exports.run(`UPDATE WalletTransaction SET memo = ? WHERE id = ?`, [zap, nwc]);
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(listPendingProposals()[0].tx_memo, zap);
const eventId = await resolveProposalAsTrade(voorstellen()[0].id, { fiatPrice: 60_000 });
assert.equal(events().find(x => x.id === eventId).note, 'zap to npub180cv…h6w6: "Dank!" · reconcile tx hash-zap');
});
it('als verkoop zet hij de fee op de gebeurtenis', async () => {
betaling(ctx.eigen, bron(ctx.eigen, 'lnd'), 'hash-zap', 3);
await syncTxProposals(ctx.eigen, ctx.btc);
const eventId = await resolveProposalAsTrade(voorstellen()[0].id, { fiatPrice: 60_000 });
const e = events().find(x => x.id === eventId);
assert.equal(e.type, 'sell');
assert.equal(e.amount, 21);
assert.equal(e.unitFee, 3);
});
});
it('slaat bewegingen over die al gekoppeld zijn', async () => {
const tx = beweging();
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?)`, [ctx.btc, 'transfer', '2024-06-01T10:00:00', 250_000, ctx.eigen, 'manual', '2024-06-01T10:00:00']);
const ev = harness.exports.lastId();
await linkTxEvent(tx, ev, 'manual');
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0,
'een beweging met een link is geen gat meer');
});
it('stelt transfer voor bij eigen custody en trade bij een beurs', async () => {
beweging({ account: ctx.eigen, txid: 'eigen' });
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(voorstellen()[0].suggestedType, 'transfer');
beweging({ account: ctx.beurs, txid: 'beurs' });
await syncTxProposals(ctx.beurs, ctx.btc);
const bijBeurs = voorstellen().find(p => p.suggestedType === 'trade');
assert.ok(bijBeurs, 'zonder eigen custody hoort een trade voorgesteld te worden');
});
it('is idempotent: een herscan levert geen dubbele voorstellen', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(voorstellen().length, 1, 'pending wordt eerst opgeruimd, dan geregenereerd');
});
it('raakt de voorstellen van een ánder account niet aan', async () => {
beweging({ account: ctx.eigen, txid: 'a' });
beweging({ account: ctx.beurs, txid: 'b' });
await syncTxProposals(ctx.eigen, ctx.btc);
await syncTxProposals(ctx.beurs, ctx.btc);
assert.equal(voorstellen().length, 2);
});
describe('suppressie van genegeerde voorstellen', () => {
it('laat een genegeerd voorstel niet terugkomen (match op walletTransactionId)', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0);
assert.deepEqual(voorstellen().map(p => p.status), ['ignored'],
'het genegeerde voorstel blijft, er komt niets nieuws bij');
});
it('onderdrukt op (account, asset, richting, bedrag), ook zonder tx-verwijzing', async () => {
harness.exports.run(
`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, NULL, NULL, ?, ?, 'transfer', 'ignored', ?)`,
[ctx.eigen, ctx.btc, 777_000, 'in', '2024-05-01T00:00:00']);
beweging({ txid: 'nieuw-na-herimport', valueSats: 777_000, direction: 'in' });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 0,
'zelfde account/asset/richting/bedrag → nog steeds onderdrukt');
});
it('een genegeerde beweging onderdrukt geen andere beweging met hetzelfde bedrag', async () => {
beweging({ txid: 'a', valueSats: 21, direction: 'out' });
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
const tweede = beweging({ txid: 'b', valueSats: 21, direction: 'out' });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1);
assert.deepEqual(voorstellen().filter(p => p.status === 'pending').map(p => p.walletTransactionId), [tweede]);
});
it('onderdrukt niet bij een ander bedrag', async () => {
beweging({ txid: 'a', valueSats: 100 });
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
beweging({ txid: 'b', valueSats: 200 });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1,
'een ander bedrag is een ander gat');
});
it('onderdrukt na hergebruik van een tx-id GEEN onverwante beweging meer', async () => {
beweging({ txid: 'oud', valueSats: 100 });
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
assert.equal(voorstellen().length, 1);
harness.exports.run(`DELETE FROM WalletTransaction`);
assert.deepEqual(voorstellen(), [], 'CASCADE: het voorstel gaat mee met zijn beweging');
beweging({ txid: 'volstrekt-andere-tx', valueSats: 999_999 });
assert.equal(await syncTxProposals(ctx.eigen, ctx.btc), 1,
'een onverwante beweging krijgt weer een gewoon voorstel');
});
});
});
describe('listPendingProposals', () => {
it('toont alleen pending, verrijkt met account- en assetnaam', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
const lijst = listPendingProposals();
assert.equal(lijst.length, 1);
assert.equal(lijst[0].account_name, 'Hardware wallet');
assert.equal(lijst[0].asset_symbol, 'BTC');
assert.equal(lijst[0].asset_decimals, 8);
});
it('verbergt een voorstel zodra de beweging gekoppeld raakt', async () => {
const tx = beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(listPendingProposals().length, 1);
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?)`, [ctx.btc, 'transfer', '2024-06-01T10:00:00', 250_000, ctx.eigen, 'manual', '2024-06-01T10:00:00']);
await linkTxEvent(tx, harness.exports.lastId(), 'manual');
assert.deepEqual(listPendingProposals(), [],
'een gekoppelde beweging is geen openstaand gat meer');
});
it('verbergt afgehandelde voorstellen', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(listPendingProposals()[0].id);
assert.deepEqual(listPendingProposals(), []);
});
it('draagt het moment van de TRANSACTIE mee en niet dat van het voorstel', async () => {
beweging({ blockTime: '2021-03-04T12:00:00' });
await syncTxProposals(ctx.eigen, ctx.btc);
const p = listPendingProposals()[0];
assert.equal(p.tx_block_time, '2021-03-04T12:00:00');
assert.notEqual(p.tx_block_time, p.detectedAt, 'anders bewijst deze test niets');
});
it('houdt een ONBEVESTIGDE beweging in de lijst, zonder moment', async () => {
beweging({ blockTime: null });
await syncTxProposals(ctx.eigen, ctx.btc);
const lijst = listPendingProposals();
assert.equal(lijst.length, 1, 'een onbevestigde beweging hoort nog steeds afgehandeld te worden');
assert.equal(lijst[0].tx_block_time, null);
assert.ok(lijst[0].detectedAt, 'dan is het moment van het voorstel het enige dat er is');
});
it('houdt een GAT-voorstel in de lijst, dat aan geen transactie hangt', async () => {
harness.exports.run(
`INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?,NULL,?,?,?,'pending',?)`,
[ctx.eigen, ctx.btc, 100_000, 'in', 'transfer', '2024-06-02T00:00:00']);
const lijst = listPendingProposals();
assert.equal(lijst.length, 1);
assert.equal(lijst[0].walletTransactionId, null);
assert.equal(lijst[0].tx_block_time, null);
});
it('sorteert op het moment van de beweging, nieuwste eerst', async () => {
beweging({ txid: 'oud', blockTime: '2019-01-01T00:00:00' });
beweging({ txid: 'nieuw', blockTime: '2025-01-01T00:00:00' });
beweging({ txid: 'midden', blockTime: '2022-01-01T00:00:00' });
await syncTxProposals(ctx.eigen, ctx.btc);
assert.deepEqual(listPendingProposals().map(p => p.transactionHash), ['nieuw', 'midden', 'oud']);
});
});
describe('resolveProposalAsTrade', () => {
const eersteVoorstel = async (opts) => {
beweging(opts);
await syncTxProposals(opts?.account ?? ctx.eigen, ctx.btc);
return voorstellen()[0].id;
};
it('maakt van een inkomende beweging een buy op het account', async () => {
const id = await eersteVoorstel({ direction: 'in' });
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 50_000, fiatFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.id, eventId);
assert.equal(e.type, 'buy');
assert.equal(e.toAccountId, ctx.eigen);
assert.equal(e.fromAccountId, null);
assert.equal(e.amount, 250_000);
assert.equal(e.source, 'reconcile');
});
it('maakt van een uitgaande beweging een sell vanaf het account', async () => {
const id = await eersteVoorstel({ direction: 'out' });
await resolveProposalAsTrade(id, { fiatPrice: 50_000, fiatFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.type, 'sell');
assert.equal(e.fromAccountId, ctx.eigen);
assert.equal(e.toAccountId, null);
});
it('eist een fiatPrice', async () => {
const id = await eersteVoorstel();
await assert.rejects(() => resolveProposalAsTrade(id, { fiatPrice: null }), /fiatPrice/);
assert.deepEqual(events(), [], 'zonder prijs mag er geen event ontstaan');
assert.equal(voorstellen()[0].status, 'pending', 'en het voorstel blijft open');
});
it('zet het voorstel op accepted_trade met een verwijzing naar het event', async () => {
const id = await eersteVoorstel();
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
const p = voorstellen()[0];
assert.equal(p.status, 'accepted_trade');
assert.equal(p.resolvedEventId, eventId);
});
it('legt een soft-koppeling met de beweging', async () => {
const id = await eersteVoorstel();
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
const l = harness.exports.all(`SELECT method, eventId FROM WalletTransactionEventLink`);
assert.deepEqual(l, [{ method: 'soft', eventId: eventId }]);
});
it('weigert een tweede afhandeling van hetzelfde voorstel', async () => {
const id = await eersteVoorstel();
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
await assert.rejects(() => resolveProposalAsTrade(id, { fiatPrice: 1 }), (e) => e.key === 'home.recHandled_accepted_trade');
assert.equal(events().length, 1, 'geen tweede event');
});
it('weigert een onbekend voorstel', async () => {
await assert.rejects(() => resolveProposalAsTrade(9999, { fiatPrice: 1 }), (e) => e.key === 'home.recUnknownProposal');
});
describe('welke datum wordt gekozen', () => {
it('gebruikt een expliciete occurredAt als die er is', async () => {
const id = await eersteVoorstel({ blockTime: '2024-06-01T10:00:00' });
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: '2024-01-01T00:00:00' });
assert.equal(events()[0].occurredAt, '2024-01-01T00:00:00');
});
it('valt terug op de blockTime van de beweging', async () => {
const id = await eersteVoorstel({ blockTime: '2024-06-01T10:00:00' });
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
assert.equal(events()[0].occurredAt, '2024-06-01T10:00:00');
});
it('valt daarna terug op het detectiemoment van het voorstel', async () => {
const id = await eersteVoorstel({ blockTime: null });
await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
const detected = harness.exports.one(`SELECT detectedAt FROM ReconcileProposal`).detectedAt;
assert.equal(events()[0].occurredAt, detected);
});
});
});
describe('resolveProposalAsTransfer', () => {
const eersteVoorstel = async (opts) => {
beweging(opts);
await syncTxProposals(ctx.eigen, ctx.btc);
return voorstellen()[0].id;
};
it('zet bij uitgaand het eigen account als bron en de tegenrekening als bestemming', async () => {
const id = await eersteVoorstel({ direction: 'out' });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.type, 'transfer');
assert.equal(e.fromAccountId, ctx.eigen);
assert.equal(e.toAccountId, ctx.beurs);
});
it('draait de richting om bij inkomend', async () => {
const id = await eersteVoorstel({ direction: 'in' });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
const e = events()[0];
assert.equal(e.fromAccountId, ctx.beurs);
assert.equal(e.toAccountId, ctx.eigen);
});
it('eist een tegenrekening', async () => {
const id = await eersteVoorstel();
await assert.rejects(() => resolveProposalAsTransfer(id, { other_account_id: null }), (e) => e.key === 'home.recNoCounter');
assert.deepEqual(events(), []);
});
it('neemt de netwerkfee over bij een uitgaande beweging', async () => {
const id = await eersteVoorstel({ direction: 'out', feeSats: 1_200 });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
assert.equal(events()[0].unitFee, 1_200);
});
it('neemt de fee NIET over bij een inkomende beweging', async () => {
const id = await eersteVoorstel({ direction: 'in', feeSats: 1_200 });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
assert.equal(events()[0].unitFee, null);
});
it('laat een expliciete unitFee voorgaan op de netwerkfee', async () => {
const id = await eersteVoorstel({ direction: 'out', feeSats: 1_200 });
await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: 99, occurredAt: null });
assert.equal(events()[0].unitFee, 99);
});
it('zet het voorstel op accepted_transfer', async () => {
const id = await eersteVoorstel();
const eventId = await resolveProposalAsTransfer(id, { other_account_id: ctx.beurs, unitFee: null, occurredAt: null });
const p = voorstellen()[0];
assert.equal(p.status, 'accepted_transfer');
assert.equal(p.resolvedEventId, eventId);
});
});
describe('resolveProposalAsInternal', () => {
const voorstelVoor = async (wtx, kind) => {
harness.exports.run(`UPDATE WalletTransaction SET kind = ? WHERE id = ?`, [kind, wtx]);
await syncTxProposals(ctx.eigen, ctx.btc);
return voorstellen().find(p => p.walletTransactionId === wtx).id;
};
it('boekt een kanaalopening als verplaatsing naar hetzelfde account: netto alleen de fee', async () => {
const wtx = beweging({ direction: 'out', valueSats: 1_000_000, feeSats: 450 });
const eventId = await resolveProposalAsInternal(await voorstelVoor(wtx, 'channel'));
const e = events().find(x => x.id === eventId);
assert.equal(e.type, 'transfer');
assert.equal(e.fromAccountId, ctx.eigen);
assert.equal(e.toAccountId, ctx.eigen);
assert.equal(e.amount, 1_000_000);
assert.equal(e.unitFee, 450);
assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), -450);
assert.equal(voorstellen()[0].status, 'accepted_transfer');
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM WalletTransactionEventLink WHERE walletTransactionId = ?`, [wtx]).n, 1);
});
it('een kanaalsluiting komt binnen zonder fee: netto nul', async () => {
const wtx = beweging({ direction: 'in', valueSats: 990_000, feeSats: 300 });
await resolveProposalAsInternal(await voorstelVoor(wtx, 'channel'));
assert.equal(events()[0].unitFee, null);
assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), 0);
});
it('een kanaal van een gekoppelde wallet op een eigen account gaat naar het account van de node', async () => {
harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt)
VALUES ('lnd', 'Node', 'node', 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`);
const conn = harness.exports.lastId();
const node = account('Lightning');
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, createdAt) VALUES (?, ?, 'lnd', '', '2026-01-01')`, [node, conn]);
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, scriptType, createdAt) VALUES (?, ?, 'xpub', 'zpub', 'p2wpkh', '2026-01-01')`, [ctx.eigen, conn]);
const wtx = beweging({ direction: 'out', valueSats: 500_000, feeSats: 200 });
const id = await voorstelVoor(wtx, 'channel');
assert.equal(listPendingProposals()[0].tx_node_account, 'Lightning');
const eventId = await resolveProposalAsInternal(id);
const e = events().find(x => x.id === eventId);
assert.deepEqual([e.type, e.fromAccountId, e.toAccountId, e.amount, e.unitFee], ['transfer', ctx.eigen, node, 500_000, 200]);
assert.equal(eventsNetByAccount(ctx.eigen, ctx.btc), -500_200);
assert.equal(eventsNetByAccount(node, ctx.btc), 500_000);
});
it('weigert een gewone beweging, die heeft een tegenpartij', async () => {
beweging({ direction: 'out' });
await syncTxProposals(ctx.eigen, ctx.btc);
await assert.rejects(() => resolveProposalAsInternal(voorstellen()[0].id), (e) => e.key === 'home.recNotInternal');
assert.deepEqual(events(), []);
assert.equal(voorstellen()[0].status, 'pending');
});
it('de lijst draagt de soort en de fee mee', async () => {
const wtx = beweging({ direction: 'out', feeSats: 12 });
await voorstelVoor(wtx, 'channel');
const p = listPendingProposals()[0];
assert.equal(p.tx_kind, 'channel');
assert.equal(p.tx_fee_sats, 12);
});
});
describe('resolveProposalAsInterest', () => {
function forward(txid = 'fwd:1:2:3') {
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt) VALUES (?, 'nwc', 'x', '2026-01-01')`, [ctx.eigen]);
harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, blockTime, createdAt)
VALUES (?, ?, 'lightning', ?, 'in', 7, '2026-10-19T10:00:00Z', '2026-10-19')`, [ctx.eigen, harness.exports.lastId(), txid]);
return harness.exports.lastId();
}
it('boekt een forward als rente op het account, en terugdraaien haalt hem weg', async () => {
const wtx = forward();
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen().find(p => p.walletTransactionId === wtx).id;
const eventId = await resolveProposalAsInterest(id);
const e = events().find(x => x.id === eventId);
assert.deepEqual([e.type, e.amount, e.toAccountId, e.fromAccountId, e.fiatPrice], ['interest', 7, ctx.eigen, null, null]);
assert.equal(e.occurredAt, '2026-10-19T10:00:00Z');
assert.equal(voorstellen()[0].status, 'accepted_interest');
await revertProposal(id);
assert.deepEqual(events(), []);
assert.equal(voorstellen()[0].status, 'pending');
});
it('weigert een gewone inkomende betaling', async () => {
const wtx = forward('hash-gewoon');
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen().find(p => p.walletTransactionId === wtx).id;
await assert.rejects(() => resolveProposalAsInterest(id), (e) => e.key === 'home.recNotForward');
assert.deepEqual(events(), []);
});
});
describe('negeren en verwijderen', () => {
it('ignoreProposal zet één voorstel op ignored', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
await ignoreProposal(voorstellen()[0].id);
assert.equal(voorstellen()[0].status, 'ignored');
});
it('ignoreProposal weigert een al afgehandeld voorstel', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen()[0].id;
await ignoreProposal(id);
await assert.rejects(() => ignoreProposal(id), (e) => e.key === 'home.recHandled_ignored');
});
it('ignoreAllPendingProposals negeert er meerdere en meldt het aantal', async () => {
beweging({ txid: 'a' });
beweging({ txid: 'b' });
beweging({ txid: 'c' });
await syncTxProposals(ctx.eigen, ctx.btc);
assert.equal(await ignoreAllPendingProposals(), 3);
assert.ok(voorstellen().every(p => p.status === 'ignored'));
});
it('ignoreAllPendingProposals schrijft niet als er niets openstaat', async () => {
const voor = harness.calls.persist;
assert.equal(await ignoreAllPendingProposals(), 0);
assert.equal(harness.calls.persist, voor, 'geen wijziging, geen schrijfactie');
});
it('deleteProposal laat het aangemaakte event en de koppeling staan', async () => {
beweging();
await syncTxProposals(ctx.eigen, ctx.btc);
const id = voorstellen()[0].id;
const eventId = await resolveProposalAsTrade(id, { fiatPrice: 1, fiatFee: null, occurredAt: null });
await deleteProposal(id);
assert.deepEqual(voorstellen(), []);
assert.equal(events().length, 1);
assert.equal(events()[0].id, eventId);
assert.equal(harness.exports.all(`SELECT * FROM WalletTransactionEventLink`).length, 1);
});
});
+412
View File
@@ -0,0 +1,412 @@
// Generated, do not edit. sha256:a513fd9262407d2ff66521427f976635538e249ad2fcc5c2180b78d3c1d79c7f
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { ENUMS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
eventCandidatesForTx, eventLinkMap, linkTxEvent, linksForEvent, linksForTx, onchainAsset,
syncAddressLinks, txCandidatesForEvent, unlinkTxEvent, walletTxEventMap
} = await import('../../server/data/tx-links.js');
const ADRES = 'bc1qvoorbeeldadresvoortests';
let ctx;
beforeEach(() => {
harness.recreate();
ctx = {
btc: asset('BTC', 'Bitcoin', 'crypto'),
account: account('Hardware wallet'),
};
});
function asset(symbol, name, kind) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, [symbol, name, toSearchName(name), kind, symbol, null, 8, 'unit']);
return harness.exports.lastId();
}
function account(naam) {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), 'wallet', 1, 1]);
return harness.exports.lastId();
}
function beweging({ direction = 'in', address = ADRES, valueSats = 100_000, txid = 'tx-1', blockTime = '2024-05-01T12:00:00' } = {}) {
harness.exports.run(
`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, address, blockHeight, blockTime, createdAt)
VALUES (?,'onchain',?,?,?,?,?,?,?)`,
[ctx.account, txid, direction, valueSats, address,
blockTime ? 840_000 : null, blockTime, '2024-05-01T12:00:00']);
return harness.exports.lastId();
}
function gebeurtenis({ type = 'transfer', extAddress = ADRES, amount = 100_000, occurredAt = '2024-05-01T12:00:00', assetId = null } = {}) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, externalAddress, createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`,
[assetId ?? ctx.btc, type, occurredAt, amount, type === 'transfer' ? null : 50000,
ctx.account, 'manual', extAddress, '2024-05-01T12:00:00']);
return harness.exports.lastId();
}
const links = () => harness.exports.all(
`SELECT walletTransactionId, eventId, method FROM WalletTransactionEventLink ORDER BY walletTransactionId, eventId`);
describe('syncAddressLinks: dezelfde payment hash op twee lightning-wallets', () => {
function bron(accountId, kind) {
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, connectionId, createdAt)
VALUES (?, ?, ?, NULL, '2026-01-01')`, [accountId, kind, `${kind}-${accountId}-${Math.random()}`]);
return harness.exports.lastId();
}
function betaling(accountId, sourceId, hash = 'hash-zap') {
harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, blockTime, createdAt)
VALUES (?, ?, 'lightning', ?, 'out', 21, '2026-10-19T10:00:00Z', '2026-10-19')`, [accountId, sourceId, hash]);
return harness.exports.lastId();
}
const koppel = (tx, ev) => harness.exports.run(`INSERT INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt)
VALUES (?, ?, 'manual', '2026-10-19')`, [tx, ev]);
it('neemt de gebeurtenis van de tweeling over, in beide richtingen', async () => {
const ev = gebeurtenis({ type: 'transfer', extAddress: null });
const lnd = betaling(ctx.account, bron(ctx.account, 'nwc'));
const nwc = betaling(ctx.account, bron(ctx.account, 'nwc'));
koppel(lnd, ev);
assert.equal(await syncAddressLinks(), 1);
assert.deepEqual(links().find(l => l.walletTransactionId === nwc), { walletTransactionId: nwc, eventId: ev, method: 'exact' });
assert.equal(await syncAddressLinks(), 0, 'de tweede keer is er niets nieuw');
});
it('koppelt niets over naar een ander account met dezelfde hash', async () => {
const ander = account('Iemand anders');
const ev = gebeurtenis({ type: 'transfer', extAddress: null });
const hier = betaling(ctx.account, bron(ctx.account, 'nwc'));
betaling(ander, bron(ander, 'nwc'));
koppel(hier, ev);
assert.equal(await syncAddressLinks(), 0);
});
it('een verschillende hash of een on-chain rij met dezelfde txid is geen tweeling', async () => {
const ev = gebeurtenis({ type: 'transfer', extAddress: null });
const een = betaling(ctx.account, bron(ctx.account, 'nwc'), 'hash-a');
betaling(ctx.account, bron(ctx.account, 'nwc'), 'hash-b');
beweging({ txid: 'hash-a', address: null });
koppel(een, ev);
assert.equal(await syncAddressLinks(), 0);
});
it('een gekoppelde on-chain rij geeft zijn gebeurtenis niet door aan een lightning-rij met dezelfde txid', async () => {
const ev = gebeurtenis({ type: 'transfer', extAddress: null });
const keten = beweging({ txid: 'hash-x', address: null });
betaling(ctx.account, bron(ctx.account, 'nwc'), 'hash-x');
koppel(keten, ev);
assert.equal(await syncAddressLinks(), 0);
});
it('een koppeling die de gebruiker brak, komt niet terug', async () => {
const ev = gebeurtenis({ type: 'transfer', extAddress: null });
const een = betaling(ctx.account, bron(ctx.account, 'nwc'));
const twee = betaling(ctx.account, bron(ctx.account, 'nwc'));
koppel(een, ev);
harness.exports.run(`INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
VALUES (?, ?, ?, 'hash-zap', 21, 'out', 'transfer', 'ignored', ?, '2026-10-19')`, [ctx.account, ctx.btc, twee, ev]);
assert.equal(await syncAddressLinks(), 0);
});
describe('wat de oude LND-booker al boekte', () => {
function oudeBoeking(ref, acc = ctx.account) {
harness.exports.run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fromAccountId, source, importReference, createdAt)
VALUES (?, 'sell', '2026-10-19T10:00:00Z', 21, 50000, ?, 'lightning', ?, '2026-10-19')`, [ctx.btc, acc, ref]);
return harness.exports.lastId();
}
it('koppelt een betaling op zijn payment hash, en de NWC-tweeling neemt het over', async () => {
const ev = oudeBoeking('ln:hash-zap');
const lnd = betaling(ctx.account, bron(ctx.account, 'nwc'));
const nwc = betaling(ctx.account, bron(ctx.account, 'nwc'));
assert.equal(await syncAddressLinks(), 2);
assert.deepEqual(links().map(l => [l.walletTransactionId, l.eventId, l.method]),
[[lnd, ev, 'exact'], [nwc, ev, 'exact']]);
});
it('koppelt een forward op zijn moment', async () => {
const ev = oudeBoeking('lnfwd:2026-10-19T10:00:00Z');
const fwd = betaling(ctx.account, bron(ctx.account, 'nwc'), 'fwd:1:2:3');
await syncAddressLinks();
assert.deepEqual(links().map(l => [l.walletTransactionId, l.eventId]), [[fwd, ev]]);
});
it('koppelt niet naar een boeking van een ander account, niet met een andere bron, en niet aan een betaling op hetzelfde moment', async () => {
oudeBoeking('ln:hash-zap', account('Iemand anders'));
harness.exports.run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fromAccountId, source, importReference, createdAt)
VALUES (?, 'sell', '2026-10-19T10:00:00Z', 21, 50000, ?, 'import', 'ln:hash-zap', '2026-10-19')`, [ctx.btc, ctx.account]);
oudeBoeking('lnfwd:2026-10-19T10:00:00Z');
betaling(ctx.account, bron(ctx.account, 'nwc'));
assert.equal(await syncAddressLinks(), 0);
});
});
});
describe('syncAddressLinks: exacte match op adres', () => {
it('koppelt een inkomende beweging aan een event met hetzelfde externe adres', async () => {
const tx = beweging();
const ev = gebeurtenis();
const n = await syncAddressLinks();
assert.equal(n, 1, 'moet één nieuwe koppeling melden');
assert.deepEqual(links(), [{ walletTransactionId: tx, eventId: ev, method: 'exact' }]);
});
it('koppelt niets bij een afwijkend adres', async () => {
beweging({ address: ADRES });
gebeurtenis({ extAddress: 'bc1qeenheelanderadres' });
assert.equal(await syncAddressLinks(), 0);
assert.deepEqual(links(), []);
});
it('negeert uitgaande bewegingen', async () => {
beweging({ direction: 'out' });
gebeurtenis();
assert.equal(await syncAddressLinks(), 0);
});
it('negeert bewegingen zonder adres', async () => {
beweging({ address: null });
gebeurtenis({ extAddress: null });
assert.equal(await syncAddressLinks(), 0,
'twee NULL-adressen zijn in SQL niet gelijk, en horen ook niet te matchen');
});
it('levert bij adres-hergebruik een kruisproduct, niet een 1-op-1-match', async () => {
const tx1 = beweging({ txid: 'tx-1' });
const tx2 = beweging({ txid: 'tx-2' });
const ev1 = gebeurtenis();
const ev2 = gebeurtenis();
assert.equal(await syncAddressLinks(), 4);
assert.deepEqual(links().map(l => [l.walletTransactionId, l.eventId]),
[[tx1, ev1], [tx1, ev2], [tx2, ev1], [tx2, ev2]]);
});
it('is idempotent: tweemaal draaien levert geen extra koppelingen', async () => {
beweging();
gebeurtenis();
assert.equal(await syncAddressLinks(), 1);
assert.equal(await syncAddressLinks(), 0, 'de tweede keer is er niets nieuw');
assert.equal(links().length, 1);
});
it('laat een handmatig verbroken koppeling niet terugkomen', async () => {
const tx = beweging();
const ev = gebeurtenis();
harness.exports.run(
`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
VALUES (?,?,?,?,?,?,?, 'ignored', ?,?)`,
[ctx.account, ctx.btc, tx, 'tx-1', 100_000, 'in', 'transfer', ev, '2024-05-02T00:00:00']);
assert.equal(await syncAddressLinks(), 0);
assert.deepEqual(links(), []);
});
it('legt elke koppeling ook vast als afgehandelde proposal (auto_linked)', async () => {
const tx = beweging();
const ev = gebeurtenis({ type: 'transfer' });
await syncAddressLinks();
const p = harness.exports.all(`SELECT * FROM ReconcileProposal`);
assert.equal(p.length, 1);
assert.equal(p[0].status, 'auto_linked');
assert.equal(p[0].walletTransactionId, tx);
assert.equal(p[0].resolvedEventId, ev);
assert.equal(p[0].suggestedType, 'transfer');
assert.equal(p[0].amount, 100_000);
});
it('stelt trade voor als het event geen transfer is', async () => {
beweging();
gebeurtenis({ type: 'buy' });
await syncAddressLinks();
assert.equal(harness.exports.one(`SELECT suggestedType FROM ReconcileProposal`).suggestedType, 'trade');
});
it('vraagt alleen om persist als er iets veranderd is', async () => {
beweging();
gebeurtenis();
const voor = harness.calls.persist;
await syncAddressLinks();
assert.ok(harness.calls.persist > voor, 'een nieuwe koppeling hoort te persisteren');
const tussen = harness.calls.persist;
await syncAddressLinks();
assert.equal(harness.calls.persist, tussen, 'zonder wijziging geen schrijfactie');
});
});
describe('onchainAsset', () => {
it('kiest BTC als dat bestaat', () => {
asset('ETH', 'Ethereum', 'crypto');
assert.equal(onchainAsset().symbol, 'BTC');
});
it('valt terug op het eerste crypto-asset op alfabet', () => {
harness.recreate();
ctx = { btc: null, account: account('Wallet') };
asset('SOL', 'Solana', 'crypto');
asset('ETH', 'Ethereum', 'crypto');
assert.equal(onchainAsset().symbol, 'ETH');
});
it('geeft null als er geen crypto-asset is', () => {
harness.recreate();
ctx = { btc: null, account: account('Wallet') };
asset('XAU', 'Goud', 'metal');
assert.equal(onchainAsset(), null);
});
});
describe('linkTxEvent / unlinkTxEvent', () => {
it('legt een handmatige koppeling met de opgegeven methode', async () => {
const tx = beweging();
const ev = gebeurtenis();
await linkTxEvent(tx, ev, 'manual');
assert.deepEqual(links(), [{ walletTransactionId: tx, eventId: ev, method: 'manual' }]);
});
it('weigert een methode buiten de SSOT-enum', async () => {
const tx = beweging();
const ev = gebeurtenis();
assert.ok(!ENUMS.LINK_METHOD.includes('gokje'));
await assert.rejects(() => linkTxEvent(tx, ev, 'gokje'), /method/);
assert.deepEqual(links(), []);
});
it('negeert een dubbele koppeling in plaats van te falen', async () => {
const tx = beweging();
const ev = gebeurtenis();
await linkTxEvent(tx, ev, 'manual');
await linkTxEvent(tx, ev, 'soft');
const l = links();
assert.equal(l.length, 1, 'UNIQUE(paar) + OR IGNORE');
assert.equal(l[0].method, 'manual', 'de eerste methode blijft staan');
});
it('verbreekt precies één paar', async () => {
const tx = beweging();
const ev1 = gebeurtenis();
const ev2 = gebeurtenis();
await linkTxEvent(tx, ev1, 'manual');
await linkTxEvent(tx, ev2, 'manual');
await unlinkTxEvent(tx, ev1);
assert.deepEqual(links().map(l => l.eventId), [ev2]);
});
});
describe('eventLinkMap / walletTxEventMap', () => {
it('geeft per event de gebruikte methode', async () => {
const tx = beweging();
const ev = gebeurtenis();
await linkTxEvent(tx, ev, 'soft');
assert.equal(eventLinkMap().get(ev), 'soft');
});
it('groepeert meerdere events onder één beweging', async () => {
const tx = beweging();
const ev1 = gebeurtenis();
const ev2 = gebeurtenis();
await linkTxEvent(tx, ev1, 'manual');
await linkTxEvent(tx, ev2, 'manual');
const rij = walletTxEventMap().get(tx);
assert.equal(rij.length, 2, 'één beweging kan aan meerdere events hangen');
assert.deepEqual(rij.map(r => r.eventId).sort((a, b) => a - b), [ev1, ev2]);
});
it('is leeg zonder koppelingen', () => {
beweging();
gebeurtenis();
assert.equal(eventLinkMap().size, 0);
assert.equal(walletTxEventMap().size, 0);
});
});
describe('eventCandidatesForTx', () => {
it('sorteert op datum-nabijheid tot de beweging', () => {
const tx = beweging({ blockTime: '2024-05-01T12:00:00' });
const ver = gebeurtenis({ occurredAt: '2024-05-20T12:00:00' });
const dicht = gebeurtenis({ occurredAt: '2024-05-02T12:00:00' });
const ids = eventCandidatesForTx(tx, ctx.btc).map(e => e.id);
assert.deepEqual(ids, [dicht, ver]);
});
it('laat events weg die al aan deze beweging hangen', async () => {
const tx = beweging();
const gekoppeld = gebeurtenis();
const vrij = gebeurtenis({ occurredAt: '2024-05-03T12:00:00' });
await linkTxEvent(tx, gekoppeld, 'manual');
assert.deepEqual(eventCandidatesForTx(tx, ctx.btc).map(e => e.id), [vrij]);
});
it('beperkt tot het gevraagde asset', () => {
const eth = asset('ETH', 'Ethereum', 'crypto');
const tx = beweging();
const btcEvent = gebeurtenis();
gebeurtenis({ assetId: eth });
assert.deepEqual(eventCandidatesForTx(tx, ctx.btc).map(e => e.id), [btcEvent]);
});
it('valt terug op createdAt als de beweging geen blockTime heeft', () => {
const tx = beweging({ blockTime: null });
const ver = gebeurtenis({ occurredAt: '2025-01-01T12:00:00' });
const dicht = gebeurtenis({ occurredAt: '2024-05-01T13:00:00' });
assert.deepEqual(eventCandidatesForTx(tx, ctx.btc).map(e => e.id), [dicht, ver]);
});
});
describe('txCandidatesForEvent', () => {
it('sorteert op datum-nabijheid tot het event', () => {
const ev = gebeurtenis({ occurredAt: '2024-05-10T12:00:00' });
const ver = beweging({ txid: 'ver', blockTime: '2024-01-01T12:00:00' });
const dicht = beweging({ txid: 'dicht', blockTime: '2024-05-09T12:00:00' });
assert.deepEqual(txCandidatesForEvent(ev).map(t => t.id), [dicht, ver]);
});
it('laat bewegingen weg die al aan dit event hangen', async () => {
const ev = gebeurtenis();
const gekoppeld = beweging({ txid: 'a' });
const vrij = beweging({ txid: 'b', blockTime: '2024-05-05T12:00:00' });
await linkTxEvent(gekoppeld, ev, 'manual');
assert.deepEqual(txCandidatesForEvent(ev).map(t => t.id), [vrij]);
});
it('toont ook uitgaande bewegingen als kandidaat', () => {
const ev = gebeurtenis();
const uit = beweging({ direction: 'out', txid: 'uit' });
assert.ok(txCandidatesForEvent(ev).some(t => t.id === uit));
});
});
describe('linksForTx / linksForEvent', () => {
it('geeft de tegenhangers met methode terug', async () => {
const tx = beweging();
const ev = gebeurtenis({ type: 'transfer' });
await linkTxEvent(tx, ev, 'soft');
const perTx = linksForTx(tx);
assert.equal(perTx.length, 1);
assert.equal(perTx[0].id, ev);
assert.equal(perTx[0].method, 'soft');
assert.equal(perTx[0].asset_symbol, 'BTC');
const perEvent = linksForEvent(ev);
assert.equal(perEvent.length, 1);
assert.equal(perEvent[0].id, tx);
assert.equal(perEvent[0].address, ADRES);
});
it('zet bewegingen zonder blockTime bovenaan bij linksForEvent', async () => {
const ev = gebeurtenis();
const bevestigd = beweging({ txid: 'a', blockTime: '2024-05-01T12:00:00' });
const onbevestigd = beweging({ txid: 'b', blockTime: null });
await linkTxEvent(bevestigd, ev, 'manual');
await linkTxEvent(onbevestigd, ev, 'manual');
assert.deepEqual(linksForEvent(ev).map(t => t.id), [onbevestigd, bevestigd]);
});
});
+150
View File
@@ -0,0 +1,150 @@
// Generated, do not edit. sha256:52052b11142a17d483d54341c9f2ba38a354f77829e27fbdc3fddff2dd5437b6
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
createEvent, eventAccountIdsByAsset, eventAssetIdsByAccount, eventDaysByAccount, eventsCountByAccount,
eventsNetByAccount, eventsWithoutImportRef, firstTaxEventDate, listEvents,
} = await import('../../server/data/events.js');
const {
bookReservation, createReservation, deleteReservation, listDueReservations, listReservations,
listReservationsForAccount, updateReservation,
} = await import('../../server/data/reservations.js');
let eur, bank, spaar;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('EUR','Euro','euro','fiat','EUR','cent',2,'unit')`);
eur = harness.exports.lastId();
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Bank','bank','bank',0,0,1,NULL)`);
bank = harness.exports.lastId();
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Spaar','spaar','bank',0,0,1,NULL)`);
spaar = harness.exports.lastId();
});
const rij = (id) => harness.exports.one(`SELECT * FROM Event WHERE id = ?`, [id]);
const reservering = (extra = {}) => createReservation({
accountId: bank, assetId: eur, direction: 'out', amount: 50_000,
occurredAt: '2026-12-01T00:00:00Z', note: 'aanslag', ...extra,
});
const isNietOpen = e => e.key === 'reservations.notOpen';
describe('vastleggen', () => {
it('zet een afschrijving op het van-account en een bijboeking op het naar-account', async () => {
const uit = await reservering();
const bij = await reservering({ direction: 'in', amount: 10_000 });
assert.deepEqual([rij(uit).type, rij(uit).fromAccountId, rij(uit).toAccountId], ['reservation', bank, null]);
assert.deepEqual([rij(bij).fromAccountId, rij(bij).toAccountId], [null, bank]);
assert.deepEqual(listReservationsForAccount(bank).map(r => r.id), [uit, bij]);
assert.deepEqual(listReservationsForAccount(spaar), []);
});
it('weigert een onbekende richting en een reservering zonder account', async () => {
await assert.rejects(() => reservering({ direction: 'opzij' }), /direction/);
await assert.rejects(() => reservering({ accountId: null }), /account/);
});
it('weigert in de datalaag én in de DDL een reservering met twee accounts of met een prijs', async () => {
const basis = { assetId: eur, type: 'reservation', occurredAt: '2026-12-01T00:00:00Z', amount: 1, source: 'manual' };
await assert.rejects(() => createEvent({ ...basis, fromAccountId: bank, toAccountId: spaar }), /exactly one account/);
await assert.rejects(() => createEvent({ ...basis, fromAccountId: bank, fiatPrice: 1 }), /no price/);
assert.throws(() => harness.exports.run(
`INSERT INTO Event (assetId, fromAccountId, toAccountId, amount, type, source, occurredAt, createdAt)
VALUES (?, ?, ?, 1, 'reservation', 'manual', '2026-12-01T00:00:00Z', '2026-10-23T00:00:00Z')`,
[eur, bank, spaar]), /CHECK/);
});
});
describe('telt nergens mee zolang hij niet geboekt is', () => {
it('blijft buiten saldo, lijsten, importpool en eerste boeking', async () => {
await reservering({ occurredAt: '2025-01-01T00:00:00Z' });
assert.equal(eventsNetByAccount(bank, eur, null), 0);
assert.equal(eventsCountByAccount(bank, eur, null), 0);
assert.deepEqual(listEvents(), []);
assert.deepEqual(eventsWithoutImportRef(), []);
assert.deepEqual(eventAssetIdsByAccount(bank), []);
assert.deepEqual(eventAccountIdsByAsset(eur), []);
assert.deepEqual(eventDaysByAccount(bank, eur, '2024-01-01', '2026-01-01'), []);
assert.equal(firstTaxEventDate(), null);
});
});
describe('vervallen', () => {
it('geeft alleen de reserveringen waarvan de datum bereikt is', async () => {
const vroeg = await reservering({ occurredAt: '2026-01-01T00:00:00Z' });
await reservering({ occurredAt: '2027-01-01T00:00:00Z' });
assert.deepEqual(listDueReservations('2026-06-01T00:00:00Z').map(r => r.id), [vroeg]);
assert.equal(listReservations().length, 2);
});
});
describe('boeken', () => {
it('maakt van dezelfde rij een verplaatsing met de echte datum en het echte bedrag', async () => {
const id = await reservering();
await bookReservation(id, { occurredAt: '2026-12-03T10:00:00Z', amount: 48_000, otherAccountId: null });
const e = rij(id);
assert.deepEqual([e.type, e.occurredAt, e.amount, e.fromAccountId, e.toAccountId, e.note],
['transfer', '2026-12-03T10:00:00Z', 48_000, bank, null, 'aanslag']);
assert.equal(eventsNetByAccount(bank, eur, null), -48_000, 'nu telt hij wel');
assert.deepEqual(listReservations(), []);
});
it('zet een tegenaccount aan de andere kant, ook bij een bijboeking', async () => {
const uit = await reservering();
await bookReservation(uit, { occurredAt: '2026-12-01T00:00:00Z', amount: 50_000, otherAccountId: spaar });
assert.deepEqual([rij(uit).fromAccountId, rij(uit).toAccountId], [bank, spaar]);
const bij = await reservering({ direction: 'in' });
await bookReservation(bij, { occurredAt: '2026-12-01T00:00:00Z', amount: 50_000, otherAccountId: spaar });
assert.deepEqual([rij(bij).fromAccountId, rij(bij).toAccountId], [spaar, bank]);
});
it('weigert een tweede keer boeken, en laat de eerste boeking staan', async () => {
const id = await reservering();
await bookReservation(id, { occurredAt: '2026-12-01T00:00:00Z', amount: 50_000 });
await assert.rejects(() => bookReservation(id, { occurredAt: '2026-12-09T00:00:00Z', amount: 1 }), isNietOpen);
assert.equal(rij(id).amount, 50_000);
});
it('weigert het eigen account als tegenkant, een bedrag van nul en een ongeldige datum', async () => {
const id = await reservering();
await assert.rejects(() => bookReservation(id, { occurredAt: '2026-12-01T00:00:00Z', amount: 50_000, otherAccountId: bank }), /another account/);
await assert.rejects(() => bookReservation(id, { occurredAt: '2026-12-01T00:00:00Z', amount: 0 }), /amount > 0/);
await assert.rejects(() => bookReservation(id, { occurredAt: 'morgen', amount: 1 }), /occurredAt/);
assert.equal(rij(id).type, 'reservation', 'en hij staat nog open');
});
});
describe('een gewone boeking is via deze module niet aan te raken', () => {
it('weigert boeken, bewerken en verwijderen van een verplaatsing', async () => {
const t = await createEvent({ assetId: eur, type: 'transfer', occurredAt: '2026-01-01T00:00:00Z', amount: 100,
fromAccountId: bank, toAccountId: spaar, source: 'manual' });
await assert.rejects(() => bookReservation(t, { occurredAt: '2026-01-02T00:00:00Z', amount: 1 }), isNietOpen);
await assert.rejects(() => updateReservation(t, { accountId: bank, assetId: eur, direction: 'out', amount: 1,
occurredAt: '2026-01-02T00:00:00Z' }), isNietOpen);
await assert.rejects(() => deleteReservation(t), isNietOpen);
assert.deepEqual([rij(t).type, rij(t).amount], ['transfer', 100]);
});
});
describe('bewerken en verwijderen', () => {
it('bewerkt bedrag, datum, richting en omschrijving, en blijft een reservering', async () => {
const id = await reservering();
await updateReservation(id, { accountId: bank, assetId: eur, direction: 'in', amount: 7, occurredAt: '2027-02-01T00:00:00Z', note: 'teruggave' });
const e = rij(id);
assert.deepEqual([e.type, e.amount, e.occurredAt, e.fromAccountId, e.toAccountId, e.note],
['reservation', 7, '2027-02-01T00:00:00Z', null, bank, 'teruggave']);
});
it('verwijdert hem zonder boeking', async () => {
const id = await reservering();
await deleteReservation(id);
assert.ok(!rij(id));
await assert.rejects(() => deleteReservation(id), isNietOpen, 'een tweede keer is er niets meer');
});
});
+383
View File
@@ -0,0 +1,383 @@
// Generated, do not edit. sha256:fb6df05be3eb789327d6d99ee25a2f208a1569610e302fe74105eb3607c7757a
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { toUtcIso } from '../../shared/time.js';
import { createDbHarness } from '../helpers/db-node.mjs';
import { CONFIG } from '../../shared/config.js';
const harness = createDbHarness({ seedPreferences: true });
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
bookDueCredit, dismissDueCredit, dueCreditsAll, dueCreditsForAccount,
} = await import('../../server/data/interest-credits.js');
const {
createSchedule, deleteSchedule, listAllSchedules, listSchedulesForAccount, updateSchedule,
} = await import('../../server/data/interest-schedules.js');
const { listTableRaw, tableColumns } = await import('../../server/data/table-viewer.js');
const { listWalletAddresses, upsertWalletAddresses } = await import('../../server/data/wallet-addresses.js');
const {
deleteAllPendingProposals, listHandledProposals, listPendingProposals, revertProposal,
} = await import('../../server/data/proposals.js');
const { deletePref, getPref, setPref } = await import('../../server/data/preferences.js');
const { listEvents } = await import('../../server/data/events.js');
let btc, accA;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
btc = harness.exports.lastId();
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Rente','rente','bank',0,0,1,NULL)`);
accA = harness.exports.lastId();
});
const afspraak = (extra = {}) => ({
accountId: accA, rateKind: 'fixed', principalAssetId: btc, payoutAssetId: btc,
principalAmount: 100_000_000, annualRate: 0.05,
startsOn: '2024-01-01', endsOn: '2099-12-31',
creditCadence: 'monthly', creditDayOfMonth: 1, note: null, ...extra,
});
describe('bookDueCredit en dismissDueCredit', () => {
async function eersteDue() {
await createSchedule(afspraak());
const due = dueCreditsForAccount(accA);
assert.ok(due.length > 0, 'voorwaarde: er staat iets te boeken');
return due[0];
}
it('boekt een echt interest-event én legt de credit vast', async () => {
const due = await eersteDue();
const eventId = await bookDueCredit(due, 4_100);
const ev = harness.exports.one(`SELECT * FROM Event WHERE id = ?`, [eventId]);
assert.equal(ev.type, 'interest');
assert.equal(ev.amount, 4_100);
assert.equal(ev.toAccountId, accA, 'rente landt op het account van de afspraak');
assert.equal(ev.assetId, btc, 'in de payout-asset');
assert.equal(ev.occurredAt, toUtcIso(`${due.periodEnd}T00:00:00`),
'gedateerd op de crediteringsdag, niet op vandaag: anders valt hij in het verkeerde belastingjaar');
assert.match(ev.occurredAt, /Z$/, 'en in UTC, zoals elke andere schrijfweg');
const credit = harness.exports.one(`SELECT * FROM InterestCredit WHERE eventId = ?`, [eventId]);
assert.equal(credit.status, 'booked');
assert.equal(credit.periodEnd, due.periodEnd);
});
it('haalt de geboekte periode uit de due-lijst', async () => {
const due = await eersteDue();
const voor = dueCreditsForAccount(accA).length;
await bookDueCredit(due, 4_100);
assert.equal(dueCreditsForAccount(accA).length, voor - 1);
});
it('legt met dismissDueCredit alleen de afhandeling vast, zonder event', async () => {
const due = await eersteDue();
const voorEvents = listEvents().length;
await dismissDueCredit(due);
assert.equal(listEvents().length, voorEvents, 'wegklikken boekt geen rente');
const credit = harness.exports.one(
`SELECT * FROM InterestCredit WHERE scheduleId = ? AND periodEnd = ?`,
[due.scheduleId, due.periodEnd]);
assert.equal(credit.status, 'dismissed');
assert.equal(credit.eventId, null);
assert.equal(dueCreditsForAccount(accA).some(d => d.periodEnd === due.periodEnd), false,
'ook een weggeklikte periode komt niet terug');
});
it('telt met dueCreditsAll over alle accounts en verrijkt met de accountnaam', async () => {
await createSchedule(afspraak());
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','bank',0,0,1,NULL)`);
const tweede = harness.exports.lastId();
await createSchedule(afspraak({ accountId: tweede }));
const alle = dueCreditsAll();
assert.ok(alle.length >= 2);
assert.deepEqual([...new Set(alle.map(d => d.account_name))].sort(), ['Rente', 'Tweede']);
});
it('biedt niets meer aan op een afgesloten account, maar laat het geboekte staan', async () => {
const due = await eersteDue();
await bookDueCredit(due, 4_100);
const geboekt = listEvents().filter(e => e.toAccountId === accA).length;
assert.ok(dueCreditsAll().some(d => d.accountId === accA), 'voorwaarde: er staat nog iets open');
harness.exports.run(`UPDATE Account SET isActive = 0, closedAt = '2026-01-01T00:00:00Z' WHERE id = ?`, [accA]);
assert.equal(dueCreditsAll().some(d => d.accountId === accA), false, 'geen nieuw aanbod meer');
assert.equal(listEvents().filter(e => e.toAccountId === accA).length, geboekt,
'de al geboekte rente blijft staan');
});
describe('boeken gaat van oud naar nieuw', () => {
it('weigert een nieuwere bijschrijving zolang er een oudere openstaat', async () => {
await createSchedule(afspraak());
const due = dueCreditsForAccount(accA);
assert.ok(due.length >= 2, 'voorwaarde: er staan er minstens twee open');
await assert.rejects(() => bookDueCredit(due[1], 4_100), (e) => e.key === 'interest.bookOlderFirst');
await assert.rejects(() => bookDueCredit(due[1], 4_100), new RegExp(due[0].periodEnd));
assert.equal(listEvents().length, 0, 'en er is niets geboekt');
});
it('laat de oudste gewoon boeken', async () => {
await createSchedule(afspraak());
const due = dueCreditsForAccount(accA);
const eventId = await bookDueCredit(due[0], 4_100);
assert.ok(eventId > 0);
});
it('geeft de volgende vrij zodra de oudste geboekt is', async () => {
await createSchedule(afspraak());
const due = dueCreditsForAccount(accA);
await bookDueCredit(due[0], 4_100);
assert.ok(await bookDueCredit(due[1], 4_200) > 0);
});
it('geeft de volgende óók vrij als de oudste genegeerd is', async () => {
await createSchedule(afspraak());
const due = dueCreditsForAccount(accA);
await dismissDueCredit(due[0]);
assert.ok(await bookDueCredit(due[1], 4_200) > 0);
});
it('kijkt per ACCOUNT en niet per afspraak', async () => {
await createSchedule(afspraak());
await createSchedule(afspraak({ startsOn: '2025-06-01' }));
const due = dueCreditsForAccount(accA);
const oudste = due.reduce((a, b) => (a.periodEnd <= b.periodEnd ? a : b));
const vanDeTweede = due.find(d => d.scheduleId !== oudste.scheduleId && d.periodEnd > oudste.periodEnd);
assert.ok(vanDeTweede, 'voorwaarde: de tweede afspraak heeft een jongere bijschrijving');
await assert.rejects(() => bookDueCredit(vanDeTweede, 1_000), (e) => e.key === 'interest.bookOlderFirst');
});
});
});
describe('interest-schedules: listings en beheer', () => {
it('geeft de afspraken van één account met asset-meta', async () => {
const id = await createSchedule(afspraak());
const r = listSchedulesForAccount(accA);
assert.equal(r.length, 1);
assert.equal(r[0].id, id);
assert.equal(r[0].payout_symbol, 'BTC');
assert.equal(r[0].payout_decimals, 8, 'de projectie rekent met deze schaal');
assert.equal(r[0].account_name, undefined, 'geen account-join: de aanroeper kent het account al');
});
it('geeft met listAllSchedules alle afspraken mét accountnaam', async () => {
await createSchedule(afspraak());
const alle = listAllSchedules();
assert.equal(alle[0].account_name, 'Rente', 'de centrale tabel toont bij welk account de afspraak hoort');
assert.equal(alle[0].principal_symbol, 'BTC');
});
it('sorteert op startdatum, nieuwste eerst', async () => {
const oud = await createSchedule(afspraak({ startsOn: '2020-01-01', endsOn: '2021-01-01' }));
const nieuw = await createSchedule(afspraak({ startsOn: '2024-01-01' }));
assert.deepEqual(listAllSchedules().map(s => s.id), [nieuw, oud]);
});
it('verplaatst een afspraak naar een ander account bij bewerken', async () => {
const id = await createSchedule(afspraak());
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','bank',0,0,1,NULL)`);
const ander = harness.exports.lastId();
await updateSchedule(id, afspraak({ accountId: ander, annualRate: 0.03 }));
assert.deepEqual(listSchedulesForAccount(accA), []);
assert.equal(listSchedulesForAccount(ander)[0].annualRate, 0.03);
});
it('eist een account bij bewerken en valideert als bij aanmaken', async () => {
const id = await createSchedule(afspraak());
await assert.rejects(() => updateSchedule(id, afspraak({ accountId: null })), (e) => e.key === 'interest.schAccountRequired');
await assert.rejects(() => updateSchedule(id, afspraak({ rateKind: 'onzin' })), /rateKind/);
await assert.rejects(() => updateSchedule(id, afspraak({ endsOn: '2023-01-01' })), (e) => e.key === 'accounts.schEndBeforeStart');
});
it('verwijdert een afspraak', async () => {
const id = await createSchedule(afspraak());
await deleteSchedule(id);
assert.deepEqual(listSchedulesForAccount(accA), []);
});
});
describe('table-viewer: tableColumns en listTableRaw', () => {
it('geeft de kolomnamen van een tabel uit de whitelist', () => {
const kolommen = tableColumns('Account');
assert.deepEqual(kolommen,
['id', 'name', 'searchName', 'description', 'kind', 'isOwnCustody', 'scanEnabled', 'isActive',
'closedAt', 'taxCategory', 'taxScope', 'wealthScope', 'createdAt', 'updatedAt'],
'in schema-volgorde, want de viewer toont ze zo');
});
it('weigert een tabel buiten de whitelist', () => {
assert.throws(() => tableColumns('sqlite_master'), /Unknown table/);
assert.throws(() => tableColumns('accounts; DROP TABLE Account'), /Unknown table/);
assert.throws(() => listTableRaw('sqlite_master'), /Unknown table/);
});
it('geeft de rijen nieuwste eerst', () => {
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','bank',0,0,1,NULL)`);
const rijen = listTableRaw('Account');
assert.deepEqual(rijen.map(r => r.name), ['Tweede', 'Rente'], 'ORDER BY rowid DESC');
});
it('werkt voor elke tabel in de whitelist', () => {
for (const naam of CONFIG.DB_TABLES) {
assert.ok(Array.isArray(listTableRaw(naam)), `${naam} gaf geen rijen-array`);
assert.ok(tableColumns(naam).length > 0, `${naam} gaf geen kolommen`);
}
});
});
describe('upsertWalletAddresses en listWalletAddresses', () => {
const adres = (extra = {}) => ({
accountId: accA, address: 'bc1qtest', chainIndex: 0, derivationIndex: 3,
automaticLabel: 'xpub', balanceSats: 500_000, transactionCount: 2, lastScannedAt: '2024-05-01T10:00:00', ...extra,
});
const rij = (address = 'bc1qtest') => harness.exports.one(
`SELECT * FROM WalletAddress WHERE address = ?`, [address]);
it('schrijft een batch adressen weg met één persist', async () => {
const voor = harness.calls.persist;
await upsertWalletAddresses([adres(), adres({ address: 'bc1qtwee' }), adres({ address: 'bc1qdrie' })]);
assert.equal(listWalletAddresses(accA).length, 3);
assert.equal(harness.calls.persist, voor + 1, 'één write voor de hele scan');
});
it('werkt bij een rescan de scan-gegevens bij', async () => {
await upsertWalletAddresses([adres({ balanceSats: 500_000, transactionCount: 2 })]);
await upsertWalletAddresses([adres({ balanceSats: 750_000, transactionCount: 3, lastScannedAt: '2024-06-01T10:00:00' })]);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM WalletAddress`).n, 1, 'UNIQUE op address');
assert.equal(rij().balanceSats, 750_000);
assert.equal(rij().transactionCount, 3);
assert.equal(rij().lastScannedAt, '2024-06-01T10:00:00');
});
it('vult een ontbrekend scanmoment zelf in', async () => {
await upsertWalletAddresses([{ accountId: accA, address: 'bc1qkaal' }]);
assert.ok(rij('bc1qkaal').lastScannedAt, 'een adres uit een scan heeft altijd een scanmoment');
assert.equal(rij('bc1qkaal').balanceSats, null, 'maar een onbekend saldo blijft leeg, niet 0');
});
it('sorteert de lijst op keten, index en adres', async () => {
await upsertWalletAddresses([
adres({ address: 'bc1qb', chainIndex: 1, derivationIndex: 0 }),
adres({ address: 'bc1qa', chainIndex: 0, derivationIndex: 5 }),
adres({ address: 'bc1qc', chainIndex: 0, derivationIndex: 1 }),
]);
assert.deepEqual(listWalletAddresses(accA).map(a => a.address), ['bc1qc', 'bc1qa', 'bc1qb'],
'keten 0 vóór 1, en daarbinnen op afgeleide index');
});
it('kijkt alleen naar het gevraagde account', async () => {
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','wallet',1,1,1,NULL)`);
const ander = harness.exports.lastId();
await upsertWalletAddresses([adres(), adres({ address: 'bc1qander', accountId: ander })]);
assert.equal(listWalletAddresses(accA).length, 1);
assert.equal(listWalletAddresses(ander).length, 1);
});
it('weigert een bron die bij een ander account hoort', async () => {
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','wallet',1,0,1,NULL)`);
const ander = harness.exports.lastId();
harness.exports.run(
`INSERT INTO WalletSource (accountId, kind, value, name, createdAt)
VALUES (?, 'xpub', 'zpub-ander', 'Andere Ledger', '2024-01-01T00:00:00')`, [ander]);
const andereBron = harness.exports.lastId();
await assert.rejects(() => upsertWalletAddresses([adres({ sourceId: andereBron })]),
/source .* belongs to account/);
assert.equal(rij(), null, 'en er blijft niets half ingevoerd staan');
});
it('laat een los toegevoegd adres zónder bron door', async () => {
await upsertWalletAddresses([adres({ sourceId: null })]);
assert.equal(rij().sourceId, null);
});
});
describe('proposals: bulk verwijderen en terugdraaien', () => {
function voorstel({ status = 'pending', amount = 100_000, resolved = null } = {}) {
harness.exports.run(
`INSERT INTO ReconcileProposal
(accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt)
VALUES (?,?, NULL, NULL, ?, 'in', 'transfer', ?, ?, '2024-05-01T00:00:00')`,
[accA, btc, amount, status, resolved]);
return harness.exports.lastId();
}
it('verwijdert alleen de openstaande voorstellen', async () => {
voorstel({ status: 'pending' });
voorstel({ status: 'pending', amount: 2 });
voorstel({ status: 'ignored', amount: 3 });
await deleteAllPendingProposals();
assert.deepEqual(listPendingProposals(), []);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM ReconcileProposal`).n, 1);
});
it('draait een afgehandeld voorstel terug naar openstaand', async () => {
const { createEvent } = await import('../../server/data/events.js');
const eventId = await createEvent({
assetId: btc, type: 'transfer', occurredAt: '2024-05-01T00:00:00', amount: 100_000,
toAccountId: accA, source: 'manual',
});
const id = voorstel({ status: 'accepted_transfer', resolved: eventId });
await revertProposal(id);
const p = harness.exports.one(`SELECT * FROM ReconcileProposal WHERE id = ?`, [id]);
assert.equal(p.status, 'pending', 'het voorstel staat weer open');
assert.equal(p.resolvedEventId, null);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Event WHERE id = ?`, [eventId]).n, 0,
'het geboekte event gaat mee weg: terugdraaien betekent ook de boeking ongedaan maken');
});
it('weigert terugdraaien van de twee standen die dat niet kunnen, elk met zijn eigen reden', async () => {
const gelinkt = voorstel({ status: 'auto_linked' });
await assert.rejects(() => revertProposal(gelinkt), (e) => e.key === 'home.recAutoLinked');
const open = voorstel({ status: 'pending', amount: 2 });
await assert.rejects(() => revertProposal(open), (e) => e.key === 'home.recStillOpen');
});
it('geeft met listHandledProposals de afgehandelde, nieuwste eerst', () => {
voorstel({ status: 'pending' });
const genegeerd = voorstel({ status: 'ignored', amount: 2 });
const gelinkt = voorstel({ status: 'auto_linked', amount: 3 });
const rijen = listHandledProposals();
assert.deepEqual(rijen.map(r => r.id), [gelinkt, genegeerd], 'alleen de niet-openstaande');
});
it('respecteert de limiet van listHandledProposals', () => {
for (let i = 0; i < 5; i++) voorstel({ status: 'ignored', amount: i + 1 });
assert.equal(listHandledProposals(2).length, 2);
});
});
describe('deletePref', () => {
it('verwijdert één voorkeur en laat de rest staan', async () => {
await setPref('table.trades', 'view', { sort: null });
await setPref('table.events', 'view', { sort: null });
await deletePref('table.trades', 'view');
assert.equal(harness.exports.one(
`SELECT COUNT(*) AS n FROM Preference WHERE scope='table.trades'`).n, 0);
assert.equal(harness.exports.one(
`SELECT COUNT(*) AS n FROM Preference WHERE scope='table.events'`).n, 1);
});
it('laat een verwijderde voorkeur terugvallen op de registry-waarde', async () => {
await setPref('app', 'theme', 'light');
assert.equal(getPref('app', 'theme'), 'light');
await deletePref('app', 'theme');
assert.equal(getPref('app', 'theme'), 'dark', 'de initiële waarde uit PREFERENCES');
});
it('doet niets bij een onbekende sleutel', async () => {
const voor = harness.exports.one(`SELECT COUNT(*) AS n FROM Preference`).n;
await deletePref('bestaat', 'niet');
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Preference`).n, voor);
});
});
+429
View File
@@ -0,0 +1,429 @@
// Generated, do not edit. sha256:5ad4e9e80b4d00212b534f31292a4fbb321ddf002172941e8c0d4d2648e0cfdc
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { listSourceTxs, setTxLabels, upsertWalletTxs, labelChannelTxs } = await import('../../server/data/wallet-txs.js');
let ctx;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Kluis','kluis','wallet',1,1,1,NULL)`);
const account = harness.exports.lastId();
harness.exports.run(
`INSERT INTO WalletSource (accountId, kind, value, name, createdAt)
VALUES (?, 'xpub', 'zpub-test', 'Ledger', '2024-01-01T00:00:00')`, [account]);
const bron = harness.exports.lastId();
harness.exports.run(
`INSERT INTO WalletSource (accountId, kind, value, name, createdAt)
VALUES (?, 'address', 'bc1qlos', 'Los adres', '2024-01-01T00:00:00')`, [account]);
ctx = { account, bron, bron2: harness.exports.lastId() };
});
const beweging = (extra = {}) => ({
accountId: ctx.account, sourceId: ctx.bron, txid: 'tx-1', direction: 'in',
valueSats: 100_000, ...extra,
});
const rij = (txid = 'tx-1') => harness.exports.one(
`SELECT * FROM WalletTransaction WHERE accountId = ? AND txid = ?`, [ctx.account, txid]);
const aantal = () => harness.exports.one(`SELECT COUNT(*) AS n FROM WalletTransaction`).n;
describe('upsertWalletTxs: nieuwe bewegingen', () => {
it('bewaart alle velden en zet createdAt zelf', async () => {
await upsertWalletTxs([beweging({
feeSats: 250, blockHeight: 840_000, blockTime: '2024-05-01T12:00:00', address: 'bc1qontvangst',
})]);
const t = rij();
assert.equal(t.sourceId, ctx.bron);
assert.equal(t.direction, 'in');
assert.equal(t.valueSats, 100_000);
assert.equal(t.feeSats, 250);
assert.equal(t.blockHeight, 840_000);
assert.equal(t.blockTime, '2024-05-01T12:00:00');
assert.equal(t.address, 'bc1qontvangst');
assert.ok(t.createdAt, 'het tijdstip komt uit de datalaag, niet van de scanner');
});
it('accepteert een beweging zonder blok- en fee-gegevens', async () => {
await upsertWalletTxs([beweging()]);
const t = rij();
assert.equal(t.feeSats, null);
assert.equal(t.blockHeight, null);
assert.equal(t.blockTime, null);
assert.equal(t.address, null);
});
it('schrijft een hele lijst in één keer weg en persisteert één keer', async () => {
const voor = harness.calls.persist;
await upsertWalletTxs([
beweging({ txid: 'tx-1' }), beweging({ txid: 'tx-2' }), beweging({ txid: 'tx-3' }),
]);
assert.equal(aantal(), 3);
assert.equal(harness.calls.persist, voor + 1, 'één persist voor de hele batch');
});
it('persisteert ook bij een lege lijst zonder te struikelen', async () => {
await upsertWalletTxs([]);
assert.equal(aantal(), 0);
});
});
describe('upsertWalletTxs: dezelfde beweging opnieuw (de herscan)', () => {
it('werkt de bestaande rij bij in plaats van een tweede te maken', async () => {
await upsertWalletTxs([beweging()]);
const eersteId = rij().id;
await upsertWalletTxs([beweging({ blockHeight: 840_123, blockTime: '2024-05-02T09:00:00', feeSats: 300 })]);
assert.equal(aantal(), 1, 'geen dubbele rij: (account, txid) is uniek');
const t = rij();
assert.equal(t.id, eersteId, 'dezelfde rij, dus koppelingen naar dit id blijven geldig');
assert.equal(t.blockHeight, 840_123, 'de bevestiging komt erbij');
assert.equal(t.feeSats, 300);
});
it('laat het label van de gebruiker staan', async () => {
await upsertWalletTxs([beweging()]);
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`);
const label = harness.exports.lastId();
await setTxLabels([rij().id], label);
await upsertWalletTxs([beweging({ blockHeight: 840_123, blockTime: '2024-05-02T09:00:00Z' })]);
assert.equal(rij().labelId, label, 'een herscan raakt het label niet');
});
it('laat createdAt op het eerste moment staan', async () => {
await upsertWalletTxs([beweging()]);
harness.exports.run(
`UPDATE WalletTransaction SET createdAt = '2020-01-01T00:00:00.000Z' WHERE txid = 'tx-1'`);
await upsertWalletTxs([beweging({ valueSats: 999 })]);
assert.equal(rij().createdAt, '2020-01-01T00:00:00.000Z',
'createdAt is wanneer wij de beweging voor het eerst zagen, niet wanneer we hem laatst zagen');
assert.equal(rij().valueSats, 999, 'de rest van de rij is wél bijgewerkt');
});
it('houdt dezelfde txid op een ánder account apart', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','wallet',1,1,1,NULL)`);
const tweede = harness.exports.lastId();
await upsertWalletTxs([beweging(), { ...beweging(), accountId: tweede, sourceId: null }]);
assert.equal(aantal(), 2);
});
it('verplaatst een beweging naar een andere bron als de scan dat zegt', async () => {
await upsertWalletTxs([beweging()]);
await upsertWalletTxs([beweging({ sourceId: ctx.bron2 })]);
assert.equal(rij().sourceId, ctx.bron2, 'sourceId staat wél in de UPDATE-lijst');
});
});
describe('listSourceTxs', () => {
it('geeft alleen de bewegingen van die bron', async () => {
await upsertWalletTxs([
beweging({ txid: 'a' }),
beweging({ txid: 'b', sourceId: ctx.bron2 }),
]);
assert.deepEqual(listSourceTxs(ctx.bron).map(t => t.txid), ['a']);
assert.deepEqual(listSourceTxs(ctx.bron2).map(t => t.txid), ['b']);
});
it('zet onbevestigde bewegingen bovenaan, daarna nieuwste blok eerst', async () => {
await upsertWalletTxs([
beweging({ txid: 'oud', blockHeight: 830_000, blockTime: '2024-01-01T00:00:00' }),
beweging({ txid: 'nieuw', blockHeight: 840_000, blockTime: '2024-06-01T00:00:00' }),
beweging({ txid: 'mempool' }),
]);
assert.deepEqual(listSourceTxs(ctx.bron).map(t => t.txid), ['mempool', 'nieuw', 'oud']);
});
it('joint de labelnaam mee en laat een beweging zonder label staan', async () => {
await upsertWalletTxs([beweging({ txid: 'met' }), beweging({ txid: 'zonder' })]);
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`);
const label = harness.exports.lastId();
await setTxLabels([rij('met').id], label);
const perTxid = Object.fromEntries(listSourceTxs(ctx.bron).map(t => [t.txid, t.label_name]));
assert.equal(perTxid.met, 'Salaris');
assert.equal(perTxid.zonder, null, 'LEFT JOIN: zonder label valt de rij niet weg');
});
it('geeft een lege lijst voor een bron zonder bewegingen', () => {
assert.deepEqual(listSourceTxs(ctx.bron), []);
});
});
describe('setTxLabels', () => {
async function drieBewegingen() {
await upsertWalletTxs([beweging({ txid: 'a' }), beweging({ txid: 'b' }), beweging({ txid: 'c' })]);
return ['a', 'b', 'c'].map(t => rij(t).id);
}
it('zet één label op meerdere bewegingen tegelijk', async () => {
const [a, b, c] = await drieBewegingen();
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`);
const label = harness.exports.lastId();
await setTxLabels([a, c], label);
assert.equal(rij('a').labelId, label);
assert.equal(rij('c').labelId, label);
assert.equal(rij('b').labelId, null, 'wat er niet in de lijst staat blijft ongemoeid');
});
it('haalt het label weg met null', async () => {
const [a] = await drieBewegingen();
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`);
await setTxLabels([a], harness.exports.lastId());
await setTxLabels([a], null);
assert.equal(rij('a').labelId, null);
});
it('weigert een lege lijst in plaats van alles te labelen', async () => {
await assert.rejects(() => setTxLabels([], 1), (e) => e.key === 'accounts.labelNoTxs');
await assert.rejects(() => setTxLabels(null, 1), (e) => e.key === 'accounts.labelNoTxs');
await assert.rejects(() => setTxLabels('niet-een-array', 1), (e) => e.key === 'accounts.labelNoTxs');
});
it('persisteert één keer voor de hele set', async () => {
const [a, b] = await drieBewegingen();
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`);
const label = harness.exports.lastId();
const voor = harness.calls.persist;
await setTxLabels([a, b], label);
assert.equal(harness.calls.persist, voor + 1);
});
});
describe('upsertWalletTxs: bron en account mogen niet botsen', () => {
it('weigert een bron die bij een ander account hoort', async () => {
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES ('Ander','ander','wallet',1,0,1,NULL)`);
const ander = harness.exports.lastId();
await assert.rejects(() => upsertWalletTxs([beweging({ accountId: ander })]),
/source .* belongs to account/);
assert.equal(aantal(), 0, 'en er blijft niets half ingevoerd staan');
});
it('weigert een bron die niet bestaat', async () => {
await assert.rejects(() => upsertWalletTxs([beweging({ sourceId: 9999 })]), /does not exist/);
});
it('laat een beweging zónder bron door', async () => {
await upsertWalletTxs([beweging({ sourceId: null })]);
assert.equal(rij().sourceId, null);
});
it('weigert ook bij het bijwerken van een bestaande beweging', async () => {
await upsertWalletTxs([beweging()]);
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt)
VALUES ('Ander','ander','wallet',1,0,1,NULL)`);
const ander = harness.exports.lastId();
harness.exports.run(
`INSERT INTO WalletSource (accountId, kind, value, name, createdAt)
VALUES (?, 'xpub', 'zpub-ander', 'Andere Ledger', '2024-01-01T00:00:00')`, [ander]);
const andereBron = harness.exports.lastId();
await assert.rejects(() => upsertWalletTxs([beweging({ sourceId: andereBron })]),
/belongs to account/);
assert.equal(rij().sourceId, ctx.bron, 'de bestaande rij is niet aangeraakt');
});
});
describe('randwaardes: een batch van duizend', () => {
it('schrijft duizend bewegingen weg met één persist', async () => {
const voor = harness.calls.persist;
await upsertWalletTxs(Array.from({ length: 1000 }, (_, i) => beweging({ txid: `bulk-${i}` })));
assert.equal(aantal(), 1000);
assert.equal(harness.calls.persist, voor + 1, 'nog steeds één persist, ook bij duizend rijen');
});
it('houdt bij dezelfde beweging TWEE KEER in één batch de laatste over', async () => {
await upsertWalletTxs([
beweging({ txid: 'dubbel', valueSats: 111 }),
beweging({ txid: 'dubbel', valueSats: 222 }),
]);
assert.equal(aantal(), 1, 'één rij, geen twee');
const rij = harness.exports.one(`SELECT valueSats FROM WalletTransaction WHERE txid = 'dubbel'`);
assert.equal(rij.valueSats, 222, 'de laatste uit de batch wint');
});
});
describe('upsertWalletTxs: lightning', () => {
const betaling = (extra = {}) => ({
accountId: ctx.account, sourceId: ctx.bron, layer: 'lightning', txid: 'hash-1', direction: 'out',
valueSats: 21, feeSats: 1, blockTime: '2026-10-19T10:00:00Z', ...extra,
});
const lnRijen = () => harness.exports.all(
`SELECT sourceId, layer, valueSats FROM WalletTransaction WHERE layer = 'lightning' ORDER BY sourceId`);
it('een betaling twee keer is één rij, bijgewerkt', async () => {
await upsertWalletTxs([betaling()]);
await upsertWalletTxs([betaling({ valueSats: 22 })]);
assert.deepEqual(lnRijen(), [{ sourceId: ctx.bron, layer: 'lightning', valueSats: 22 }]);
});
it('dezelfde hash op twee bronnen van één account: twee rijen, en geen die de ander overschrijft', async () => {
await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2, valueSats: 99 })]);
assert.deepEqual(lnRijen(), [
{ sourceId: ctx.bron, layer: 'lightning', valueSats: 21 },
{ sourceId: ctx.bron2, layer: 'lightning', valueSats: 99 }]);
});
it('een payment hash die ook als on-chain txid bestaat, botst niet', async () => {
await upsertWalletTxs([beweging({ txid: 'hash-1' }), betaling()]);
assert.equal(aantal(), 2);
});
it('weigert een lightning-beweging zonder bron, zonder tijd of met een blokhoogte, en een onbekende laag', async () => {
await assert.rejects(upsertWalletTxs([betaling({ sourceId: null })]), /needs its source/);
await assert.rejects(upsertWalletTxs([betaling({ blockTime: null })]), /moment it settled/);
await assert.rejects(upsertWalletTxs([betaling({ blockHeight: 900_000 })]), /no block height/);
await assert.rejects(upsertWalletTxs([betaling({ layer: 'bliksem' })]), /Unknown wallet movement layer/);
assert.equal(aantal(), 0);
});
it('bewaart de memo compact: van een zap-verzoek alleen wat gelezen wordt', async () => {
const zap = { kind: 9734, pubkey: 'c'.repeat(64), content: 'Hoi', tags: [['p', 'd'.repeat(64)], ['relays', 'wss://x']], sig: 'y' };
await upsertWalletTxs([betaling({ memo: zap })]);
const memo = harness.exports.one(`SELECT memo FROM WalletTransaction WHERE layer = 'lightning'`).memo;
assert.deepEqual(JSON.parse(memo), { kind: 9734, pubkey: 'c'.repeat(64), content: 'Hoi', tags: [['p', 'd'.repeat(64)]] });
await upsertWalletTxs([betaling({ memo: null })]);
assert.equal(harness.exports.one(`SELECT memo FROM WalletTransaction WHERE layer = 'lightning'`).memo, null);
});
it('een betaling aan uzelf houdt zijn soort, en een lightning-rij kan geen kanaal zijn', async () => {
await upsertWalletTxs([betaling({ kind: 'self' })]);
assert.equal(harness.exports.one(`SELECT kind FROM WalletTransaction WHERE layer = 'lightning'`).kind, 'self');
await assert.rejects(upsertWalletTxs([betaling({ txid: 'hash-2', kind: 'channel' })]), /ordinary or 'self'/);
});
it('zonder laag is een beweging on-chain, zoals de scanner hem altijd stuurde', async () => {
await upsertWalletTxs([beweging()]);
assert.equal(rij().layer, 'onchain');
});
describe('het label van een tweeling', () => {
const labelVan = (bron, txid = 'hash-1') => harness.exports.one(
`SELECT labelId FROM WalletTransaction WHERE sourceId = ? AND txid = ? AND layer = 'lightning'`, [bron, txid]).labelId;
const idVan = bron => harness.exports.one(
`SELECT id FROM WalletTransaction WHERE sourceId = ? AND layer = 'lightning'`, [bron]).id;
const nieuwLabel = naam => {
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES (?, ?)`, [naam, naam.toLowerCase()]);
return harness.exports.lastId();
};
it('setTxLabels zet het label ook op de tweeling, en haalt het daar ook weg', async () => {
await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2 })]);
const zap = nieuwLabel('Zap');
await setTxLabels([idVan(ctx.bron2)], zap);
assert.equal(labelVan(ctx.bron), zap);
await setTxLabels([idVan(ctx.bron)], null);
assert.equal(labelVan(ctx.bron2), null);
});
it('raakt geen on-chain beweging met dezelfde txid en geen andere betaling', async () => {
await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2 }), beweging({ txid: 'hash-1' }),
betaling({ sourceId: ctx.bron2, txid: 'hash-2' })]);
await setTxLabels([idVan(ctx.bron)], nieuwLabel('Zap'));
assert.equal(harness.exports.one(
`SELECT labelId FROM WalletTransaction WHERE layer = 'onchain' AND txid = 'hash-1'`).labelId, null);
assert.equal(labelVan(ctx.bron2, 'hash-2'), null);
});
it('een tweeling die later binnenkomt, neemt het label over', async () => {
await upsertWalletTxs([betaling()]);
const zap = nieuwLabel('Zap');
await setTxLabels([idVan(ctx.bron)], zap);
await upsertWalletTxs([betaling({ sourceId: ctx.bron2 })]);
assert.equal(labelVan(ctx.bron2), zap);
});
it('overschrijft bij het bijwerken nooit een eigen label', async () => {
await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2 })]);
const zap = nieuwLabel('Zap');
const eigen = nieuwLabel('Eigen');
harness.exports.run(`UPDATE WalletTransaction SET labelId = ? WHERE sourceId = ?`, [zap, ctx.bron]);
harness.exports.run(`UPDATE WalletTransaction SET labelId = ? WHERE sourceId = ?`, [eigen, ctx.bron2]);
await upsertWalletTxs([betaling({ sourceId: ctx.bron2, valueSats: 22 })]);
assert.equal(labelVan(ctx.bron2), eigen);
});
});
});
describe('labelChannelTxs', () => {
const TX = n => n.repeat(64).slice(0, 64);
const labelVan = txid => harness.exports.one(
`SELECT l.name FROM WalletTransaction t LEFT JOIN Label l ON l.id = t.labelId WHERE t.txid = ?`, [txid])?.name ?? null;
it('zet het label op een on-chain beweging zonder label, en maakt het label één keer', async () => {
await upsertWalletTxs([beweging({ txid: TX('a') }), beweging({ txid: TX('b') })]);
assert.equal(await labelChannelTxs(ctx.account, [TX('a'), TX('c')]), 1);
assert.equal(labelVan(TX('a')), 'Lightning-kanaal');
assert.equal(labelVan(TX('b')), null);
assert.equal(await labelChannelTxs(ctx.account, [TX('b')]), 1);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Label`).n, 1, 'het label bestaat één keer');
});
it('laat een label van de gebruiker staan', async () => {
await upsertWalletTxs([beweging({ txid: TX('a') })]);
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Mijn kanaal', 'mijn kanaal')`);
const eigen = harness.exports.lastId();
await setTxLabels([rij(TX('a')).id], eigen);
assert.equal(await labelChannelTxs(ctx.account, [TX('a')]), 0);
assert.equal(labelVan(TX('a')), 'Mijn kanaal');
});
it('raakt geen lightning-rij en geen ander account', async () => {
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Ander','ander','wallet',1,1,1,NULL)`);
const ander = harness.exports.lastId();
await upsertWalletTxs([
{ accountId: ctx.account, sourceId: ctx.bron, layer: 'lightning', txid: TX('a'), direction: 'out', valueSats: 1, blockTime: '2026-10-19T10:00:00Z' },
{ accountId: ander, txid: TX('a'), direction: 'out', valueSats: 1 },
]);
assert.equal(await labelChannelTxs(ctx.account, [TX('a')]), 0);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Label`).n, 0, 'geen label voor niets');
});
it('zet de soort kanaal, ook waar de gebruiker een eigen label heeft, en een herscan laat hem staan', async () => {
await upsertWalletTxs([beweging({ txid: TX('a') }), beweging({ txid: TX('b') })]);
harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Mijn kanaal', 'mijn kanaal')`);
await setTxLabels([rij(TX('a')).id], harness.exports.lastId());
await labelChannelTxs(ctx.account, [TX('a')]);
assert.equal(rij(TX('a')).kind, 'channel');
assert.equal(rij(TX('b')).kind, null);
await upsertWalletTxs([beweging({ txid: TX('a') })]);
assert.equal(rij(TX('a')).kind, 'channel');
});
it('neemt het account van een gekoppelde on-chain wallet mee, en geen ongekoppeld account', async () => {
harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt)
VALUES ('lnd', 'Node', 'node', 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`);
const conn = harness.exports.lastId();
const nieuwAccount = naam => {
harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?, ?, 'wallet', 1, 1, 1, NULL)`, [naam, naam]);
return harness.exports.lastId();
};
const koud = nieuwAccount('Koud');
const los = nieuwAccount('Los');
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, createdAt) VALUES (?, ?, 'lnd', '', '2026-01-01')`, [ctx.account, conn]);
harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, scriptType, createdAt) VALUES (?, ?, 'xpub', 'zpub', 'p2wpkh', '2026-01-01')`, [koud, conn]);
await upsertWalletTxs([{ accountId: koud, txid: TX('a'), direction: 'out', valueSats: 1 },
{ accountId: los, txid: TX('a'), direction: 'out', valueSats: 1 }]);
await labelChannelTxs(ctx.account, [TX('a')]);
const soort = acc => harness.exports.one(`SELECT kind FROM WalletTransaction WHERE accountId = ?`, [acc]).kind;
assert.equal(soort(koud), 'channel');
assert.equal(soort(los), null);
});
it('weigert iets anders dan een lijst txids in kleine hex', async () => {
await assert.rejects(labelChannelTxs(ctx.account, ['niet-hex']), /lowercase hex/);
await assert.rejects(labelChannelTxs(ctx.account, [TX('A')]), /lowercase hex/);
await assert.rejects(labelChannelTxs(null, [TX('a')]), /account id/);
assert.equal(await labelChannelTxs(ctx.account, []), 0);
});
});