// Generated, do not edit. sha256:5ad4e9e80b4d00212b534f31292a4fbb321ddf002172941e8c0d4d2648e0cfdc import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { createDbHarness } from '../helpers/db-node.mjs'; const harness = createDbHarness(); mock.module('../../server/db.js', { namedExports: harness.exports }); const { listSourceTxs, setTxLabels, upsertWalletTxs, labelChannelTxs } = await import('../../server/data/wallet-txs.js'); let ctx; beforeEach(() => { harness.recreate(); harness.exports.run( `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Kluis','kluis','wallet',1,1,1,NULL)`); const account = harness.exports.lastId(); harness.exports.run( `INSERT INTO WalletSource (accountId, kind, value, name, createdAt) VALUES (?, 'xpub', 'zpub-test', 'Ledger', '2024-01-01T00:00:00')`, [account]); const bron = harness.exports.lastId(); harness.exports.run( `INSERT INTO WalletSource (accountId, kind, value, name, createdAt) VALUES (?, 'address', 'bc1qlos', 'Los adres', '2024-01-01T00:00:00')`, [account]); ctx = { account, bron, bron2: harness.exports.lastId() }; }); const beweging = (extra = {}) => ({ accountId: ctx.account, sourceId: ctx.bron, txid: 'tx-1', direction: 'in', valueSats: 100_000, ...extra, }); const rij = (txid = 'tx-1') => harness.exports.one( `SELECT * FROM WalletTransaction WHERE accountId = ? AND txid = ?`, [ctx.account, txid]); const aantal = () => harness.exports.one(`SELECT COUNT(*) AS n FROM WalletTransaction`).n; describe('upsertWalletTxs: nieuwe bewegingen', () => { it('bewaart alle velden en zet createdAt zelf', async () => { await upsertWalletTxs([beweging({ feeSats: 250, blockHeight: 840_000, blockTime: '2024-05-01T12:00:00', address: 'bc1qontvangst', })]); const t = rij(); assert.equal(t.sourceId, ctx.bron); assert.equal(t.direction, 'in'); assert.equal(t.valueSats, 100_000); assert.equal(t.feeSats, 250); assert.equal(t.blockHeight, 840_000); assert.equal(t.blockTime, '2024-05-01T12:00:00'); assert.equal(t.address, 'bc1qontvangst'); assert.ok(t.createdAt, 'het tijdstip komt uit de datalaag, niet van de scanner'); }); it('accepteert een beweging zonder blok- en fee-gegevens', async () => { await upsertWalletTxs([beweging()]); const t = rij(); assert.equal(t.feeSats, null); assert.equal(t.blockHeight, null); assert.equal(t.blockTime, null); assert.equal(t.address, null); }); it('schrijft een hele lijst in één keer weg en persisteert één keer', async () => { const voor = harness.calls.persist; await upsertWalletTxs([ beweging({ txid: 'tx-1' }), beweging({ txid: 'tx-2' }), beweging({ txid: 'tx-3' }), ]); assert.equal(aantal(), 3); assert.equal(harness.calls.persist, voor + 1, 'één persist voor de hele batch'); }); it('persisteert ook bij een lege lijst zonder te struikelen', async () => { await upsertWalletTxs([]); assert.equal(aantal(), 0); }); }); describe('upsertWalletTxs: dezelfde beweging opnieuw (de herscan)', () => { it('werkt de bestaande rij bij in plaats van een tweede te maken', async () => { await upsertWalletTxs([beweging()]); const eersteId = rij().id; await upsertWalletTxs([beweging({ blockHeight: 840_123, blockTime: '2024-05-02T09:00:00', feeSats: 300 })]); assert.equal(aantal(), 1, 'geen dubbele rij: (account, txid) is uniek'); const t = rij(); assert.equal(t.id, eersteId, 'dezelfde rij, dus koppelingen naar dit id blijven geldig'); assert.equal(t.blockHeight, 840_123, 'de bevestiging komt erbij'); assert.equal(t.feeSats, 300); }); it('laat het label van de gebruiker staan', async () => { await upsertWalletTxs([beweging()]); harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`); const label = harness.exports.lastId(); await setTxLabels([rij().id], label); await upsertWalletTxs([beweging({ blockHeight: 840_123, blockTime: '2024-05-02T09:00:00Z' })]); assert.equal(rij().labelId, label, 'een herscan raakt het label niet'); }); it('laat createdAt op het eerste moment staan', async () => { await upsertWalletTxs([beweging()]); harness.exports.run( `UPDATE WalletTransaction SET createdAt = '2020-01-01T00:00:00.000Z' WHERE txid = 'tx-1'`); await upsertWalletTxs([beweging({ valueSats: 999 })]); assert.equal(rij().createdAt, '2020-01-01T00:00:00.000Z', 'createdAt is wanneer wij de beweging voor het eerst zagen, niet wanneer we hem laatst zagen'); assert.equal(rij().valueSats, 999, 'de rest van de rij is wél bijgewerkt'); }); it('houdt dezelfde txid op een ánder account apart', async () => { harness.exports.run( `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES ('Tweede','tweede','wallet',1,1,1,NULL)`); const tweede = harness.exports.lastId(); await upsertWalletTxs([beweging(), { ...beweging(), accountId: tweede, sourceId: null }]); assert.equal(aantal(), 2); }); it('verplaatst een beweging naar een andere bron als de scan dat zegt', async () => { await upsertWalletTxs([beweging()]); await upsertWalletTxs([beweging({ sourceId: ctx.bron2 })]); assert.equal(rij().sourceId, ctx.bron2, 'sourceId staat wél in de UPDATE-lijst'); }); }); describe('listSourceTxs', () => { it('geeft alleen de bewegingen van die bron', async () => { await upsertWalletTxs([ beweging({ txid: 'a' }), beweging({ txid: 'b', sourceId: ctx.bron2 }), ]); assert.deepEqual(listSourceTxs(ctx.bron).map(t => t.txid), ['a']); assert.deepEqual(listSourceTxs(ctx.bron2).map(t => t.txid), ['b']); }); it('zet onbevestigde bewegingen bovenaan, daarna nieuwste blok eerst', async () => { await upsertWalletTxs([ beweging({ txid: 'oud', blockHeight: 830_000, blockTime: '2024-01-01T00:00:00' }), beweging({ txid: 'nieuw', blockHeight: 840_000, blockTime: '2024-06-01T00:00:00' }), beweging({ txid: 'mempool' }), ]); assert.deepEqual(listSourceTxs(ctx.bron).map(t => t.txid), ['mempool', 'nieuw', 'oud']); }); it('joint de labelnaam mee en laat een beweging zonder label staan', async () => { await upsertWalletTxs([beweging({ txid: 'met' }), beweging({ txid: 'zonder' })]); harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`); const label = harness.exports.lastId(); await setTxLabels([rij('met').id], label); const perTxid = Object.fromEntries(listSourceTxs(ctx.bron).map(t => [t.txid, t.label_name])); assert.equal(perTxid.met, 'Salaris'); assert.equal(perTxid.zonder, null, 'LEFT JOIN: zonder label valt de rij niet weg'); }); it('geeft een lege lijst voor een bron zonder bewegingen', () => { assert.deepEqual(listSourceTxs(ctx.bron), []); }); }); describe('setTxLabels', () => { async function drieBewegingen() { await upsertWalletTxs([beweging({ txid: 'a' }), beweging({ txid: 'b' }), beweging({ txid: 'c' })]); return ['a', 'b', 'c'].map(t => rij(t).id); } it('zet één label op meerdere bewegingen tegelijk', async () => { const [a, b, c] = await drieBewegingen(); harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`); const label = harness.exports.lastId(); await setTxLabels([a, c], label); assert.equal(rij('a').labelId, label); assert.equal(rij('c').labelId, label); assert.equal(rij('b').labelId, null, 'wat er niet in de lijst staat blijft ongemoeid'); }); it('haalt het label weg met null', async () => { const [a] = await drieBewegingen(); harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`); await setTxLabels([a], harness.exports.lastId()); await setTxLabels([a], null); assert.equal(rij('a').labelId, null); }); it('weigert een lege lijst in plaats van alles te labelen', async () => { await assert.rejects(() => setTxLabels([], 1), (e) => e.key === 'accounts.labelNoTxs'); await assert.rejects(() => setTxLabels(null, 1), (e) => e.key === 'accounts.labelNoTxs'); await assert.rejects(() => setTxLabels('niet-een-array', 1), (e) => e.key === 'accounts.labelNoTxs'); }); it('persisteert één keer voor de hele set', async () => { const [a, b] = await drieBewegingen(); harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Salaris','salaris')`); const label = harness.exports.lastId(); const voor = harness.calls.persist; await setTxLabels([a, b], label); assert.equal(harness.calls.persist, voor + 1); }); }); describe('upsertWalletTxs: bron en account mogen niet botsen', () => { 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(); await assert.rejects(() => upsertWalletTxs([beweging({ accountId: ander })]), /source .* belongs to account/); assert.equal(aantal(), 0, 'en er blijft niets half ingevoerd staan'); }); it('weigert een bron die niet bestaat', async () => { await assert.rejects(() => upsertWalletTxs([beweging({ sourceId: 9999 })]), /does not exist/); }); it('laat een beweging zónder bron door', async () => { await upsertWalletTxs([beweging({ sourceId: null })]); assert.equal(rij().sourceId, null); }); it('weigert ook bij het bijwerken van een bestaande beweging', async () => { await upsertWalletTxs([beweging()]); 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(() => upsertWalletTxs([beweging({ sourceId: andereBron })]), /belongs to account/); assert.equal(rij().sourceId, ctx.bron, 'de bestaande rij is niet aangeraakt'); }); }); describe('randwaardes: een batch van duizend', () => { it('schrijft duizend bewegingen weg met één persist', async () => { const voor = harness.calls.persist; await upsertWalletTxs(Array.from({ length: 1000 }, (_, i) => beweging({ txid: `bulk-${i}` }))); assert.equal(aantal(), 1000); assert.equal(harness.calls.persist, voor + 1, 'nog steeds één persist, ook bij duizend rijen'); }); it('houdt bij dezelfde beweging TWEE KEER in één batch de laatste over', async () => { await upsertWalletTxs([ beweging({ txid: 'dubbel', valueSats: 111 }), beweging({ txid: 'dubbel', valueSats: 222 }), ]); assert.equal(aantal(), 1, 'één rij, geen twee'); const rij = harness.exports.one(`SELECT valueSats FROM WalletTransaction WHERE txid = 'dubbel'`); assert.equal(rij.valueSats, 222, 'de laatste uit de batch wint'); }); }); describe('upsertWalletTxs: lightning', () => { const betaling = (extra = {}) => ({ accountId: ctx.account, sourceId: ctx.bron, layer: 'lightning', txid: 'hash-1', direction: 'out', valueSats: 21, feeSats: 1, blockTime: '2026-10-19T10:00:00Z', ...extra, }); const lnRijen = () => harness.exports.all( `SELECT sourceId, layer, valueSats FROM WalletTransaction WHERE layer = 'lightning' ORDER BY sourceId`); it('een betaling twee keer is één rij, bijgewerkt', async () => { await upsertWalletTxs([betaling()]); await upsertWalletTxs([betaling({ valueSats: 22 })]); assert.deepEqual(lnRijen(), [{ sourceId: ctx.bron, layer: 'lightning', valueSats: 22 }]); }); it('dezelfde hash op twee bronnen van één account: twee rijen, en geen die de ander overschrijft', async () => { await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2, valueSats: 99 })]); assert.deepEqual(lnRijen(), [ { sourceId: ctx.bron, layer: 'lightning', valueSats: 21 }, { sourceId: ctx.bron2, layer: 'lightning', valueSats: 99 }]); }); it('een payment hash die ook als on-chain txid bestaat, botst niet', async () => { await upsertWalletTxs([beweging({ txid: 'hash-1' }), betaling()]); assert.equal(aantal(), 2); }); it('weigert een lightning-beweging zonder bron, zonder tijd of met een blokhoogte, en een onbekende laag', async () => { await assert.rejects(upsertWalletTxs([betaling({ sourceId: null })]), /needs its source/); await assert.rejects(upsertWalletTxs([betaling({ blockTime: null })]), /moment it settled/); await assert.rejects(upsertWalletTxs([betaling({ blockHeight: 900_000 })]), /no block height/); await assert.rejects(upsertWalletTxs([betaling({ layer: 'bliksem' })]), /Unknown wallet movement layer/); assert.equal(aantal(), 0); }); it('bewaart de memo compact: van een zap-verzoek alleen wat gelezen wordt', async () => { const zap = { kind: 9734, pubkey: 'c'.repeat(64), content: 'Hoi', tags: [['p', 'd'.repeat(64)], ['relays', 'wss://x']], sig: 'y' }; await upsertWalletTxs([betaling({ memo: zap })]); const memo = harness.exports.one(`SELECT memo FROM WalletTransaction WHERE layer = 'lightning'`).memo; assert.deepEqual(JSON.parse(memo), { kind: 9734, pubkey: 'c'.repeat(64), content: 'Hoi', tags: [['p', 'd'.repeat(64)]] }); await upsertWalletTxs([betaling({ memo: null })]); assert.equal(harness.exports.one(`SELECT memo FROM WalletTransaction WHERE layer = 'lightning'`).memo, null); }); it('een betaling aan uzelf houdt zijn soort, en een lightning-rij kan geen kanaal zijn', async () => { await upsertWalletTxs([betaling({ kind: 'self' })]); assert.equal(harness.exports.one(`SELECT kind FROM WalletTransaction WHERE layer = 'lightning'`).kind, 'self'); await assert.rejects(upsertWalletTxs([betaling({ txid: 'hash-2', kind: 'channel' })]), /ordinary or 'self'/); }); it('zonder laag is een beweging on-chain, zoals de scanner hem altijd stuurde', async () => { await upsertWalletTxs([beweging()]); assert.equal(rij().layer, 'onchain'); }); describe('het label van een tweeling', () => { const labelVan = (bron, txid = 'hash-1') => harness.exports.one( `SELECT labelId FROM WalletTransaction WHERE sourceId = ? AND txid = ? AND layer = 'lightning'`, [bron, txid]).labelId; const idVan = bron => harness.exports.one( `SELECT id FROM WalletTransaction WHERE sourceId = ? AND layer = 'lightning'`, [bron]).id; const nieuwLabel = naam => { harness.exports.run(`INSERT INTO Label (name, searchName) VALUES (?, ?)`, [naam, naam.toLowerCase()]); return harness.exports.lastId(); }; it('setTxLabels zet het label ook op de tweeling, en haalt het daar ook weg', async () => { await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2 })]); const zap = nieuwLabel('Zap'); await setTxLabels([idVan(ctx.bron2)], zap); assert.equal(labelVan(ctx.bron), zap); await setTxLabels([idVan(ctx.bron)], null); assert.equal(labelVan(ctx.bron2), null); }); it('raakt geen on-chain beweging met dezelfde txid en geen andere betaling', async () => { await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2 }), beweging({ txid: 'hash-1' }), betaling({ sourceId: ctx.bron2, txid: 'hash-2' })]); await setTxLabels([idVan(ctx.bron)], nieuwLabel('Zap')); assert.equal(harness.exports.one( `SELECT labelId FROM WalletTransaction WHERE layer = 'onchain' AND txid = 'hash-1'`).labelId, null); assert.equal(labelVan(ctx.bron2, 'hash-2'), null); }); it('een tweeling die later binnenkomt, neemt het label over', async () => { await upsertWalletTxs([betaling()]); const zap = nieuwLabel('Zap'); await setTxLabels([idVan(ctx.bron)], zap); await upsertWalletTxs([betaling({ sourceId: ctx.bron2 })]); assert.equal(labelVan(ctx.bron2), zap); }); it('overschrijft bij het bijwerken nooit een eigen label', async () => { await upsertWalletTxs([betaling(), betaling({ sourceId: ctx.bron2 })]); const zap = nieuwLabel('Zap'); const eigen = nieuwLabel('Eigen'); harness.exports.run(`UPDATE WalletTransaction SET labelId = ? WHERE sourceId = ?`, [zap, ctx.bron]); harness.exports.run(`UPDATE WalletTransaction SET labelId = ? WHERE sourceId = ?`, [eigen, ctx.bron2]); await upsertWalletTxs([betaling({ sourceId: ctx.bron2, valueSats: 22 })]); assert.equal(labelVan(ctx.bron2), eigen); }); }); }); describe('labelChannelTxs', () => { const TX = n => n.repeat(64).slice(0, 64); const labelVan = txid => harness.exports.one( `SELECT l.name FROM WalletTransaction t LEFT JOIN Label l ON l.id = t.labelId WHERE t.txid = ?`, [txid])?.name ?? null; it('zet het label op een on-chain beweging zonder label, en maakt het label één keer', async () => { await upsertWalletTxs([beweging({ txid: TX('a') }), beweging({ txid: TX('b') })]); assert.equal(await labelChannelTxs(ctx.account, [TX('a'), TX('c')]), 1); assert.equal(labelVan(TX('a')), 'Lightning-kanaal'); assert.equal(labelVan(TX('b')), null); assert.equal(await labelChannelTxs(ctx.account, [TX('b')]), 1); assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Label`).n, 1, 'het label bestaat één keer'); }); it('laat een label van de gebruiker staan', async () => { await upsertWalletTxs([beweging({ txid: TX('a') })]); harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Mijn kanaal', 'mijn kanaal')`); const eigen = harness.exports.lastId(); await setTxLabels([rij(TX('a')).id], eigen); assert.equal(await labelChannelTxs(ctx.account, [TX('a')]), 0); assert.equal(labelVan(TX('a')), 'Mijn kanaal'); }); it('raakt geen lightning-rij en geen ander 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 upsertWalletTxs([ { accountId: ctx.account, sourceId: ctx.bron, layer: 'lightning', txid: TX('a'), direction: 'out', valueSats: 1, blockTime: '2026-10-19T10:00:00Z' }, { accountId: ander, txid: TX('a'), direction: 'out', valueSats: 1 }, ]); assert.equal(await labelChannelTxs(ctx.account, [TX('a')]), 0); assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Label`).n, 0, 'geen label voor niets'); }); it('zet de soort kanaal, ook waar de gebruiker een eigen label heeft, en een herscan laat hem staan', async () => { await upsertWalletTxs([beweging({ txid: TX('a') }), beweging({ txid: TX('b') })]); harness.exports.run(`INSERT INTO Label (name, searchName) VALUES ('Mijn kanaal', 'mijn kanaal')`); await setTxLabels([rij(TX('a')).id], harness.exports.lastId()); await labelChannelTxs(ctx.account, [TX('a')]); assert.equal(rij(TX('a')).kind, 'channel'); assert.equal(rij(TX('b')).kind, null); await upsertWalletTxs([beweging({ txid: TX('a') })]); assert.equal(rij(TX('a')).kind, 'channel'); }); it('neemt het account van een gekoppelde on-chain wallet mee, en geen ongekoppeld account', async () => { harness.exports.run(`INSERT INTO Connection (kind, name, searchName, url, isActive, isSystem, createdAt, updatedAt) VALUES ('lnd', 'Node', 'node', 'https://node.test', 1, 0, '2024-01-01', '2024-01-01')`); const conn = harness.exports.lastId(); const nieuwAccount = naam => { harness.exports.run(`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?, ?, 'wallet', 1, 1, 1, NULL)`, [naam, naam]); return harness.exports.lastId(); }; const koud = nieuwAccount('Koud'); const los = nieuwAccount('Los'); harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, createdAt) VALUES (?, ?, 'lnd', '', '2026-01-01')`, [ctx.account, conn]); harness.exports.run(`INSERT INTO WalletSource (accountId, connectionId, kind, value, scriptType, createdAt) VALUES (?, ?, 'xpub', 'zpub', 'p2wpkh', '2026-01-01')`, [koud, conn]); await upsertWalletTxs([{ accountId: koud, txid: TX('a'), direction: 'out', valueSats: 1 }, { accountId: los, txid: TX('a'), direction: 'out', valueSats: 1 }]); await labelChannelTxs(ctx.account, [TX('a')]); const soort = acc => harness.exports.one(`SELECT kind FROM WalletTransaction WHERE accountId = ?`, [acc]).kind; assert.equal(soort(koud), 'channel'); assert.equal(soort(los), null); }); it('weigert iets anders dan een lijst txids in kleine hex', async () => { await assert.rejects(labelChannelTxs(ctx.account, ['niet-hex']), /lowercase hex/); await assert.rejects(labelChannelTxs(ctx.account, [TX('A')]), /lowercase hex/); await assert.rejects(labelChannelTxs(null, [TX('a')]), /account id/); assert.equal(await labelChannelTxs(ctx.account, []), 0); }); });