// 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'); }); });