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

+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);
});
});