365 lines
15 KiB
JavaScript
365 lines
15 KiB
JavaScript
// 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');
|
|
});
|
|
});
|