384 lines
18 KiB
JavaScript
384 lines
18 KiB
JavaScript
// 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);
|
|
});
|
|
});
|