Files
AtreidesAssetsFree/tests/repo/notifications.test.mjs
T

364 lines
15 KiB
JavaScript
Raw Normal View History

2026-10-29 11:57:29 +01:00
// 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');
});
});