// Generated, do not edit. sha256:a513fd9262407d2ff66521427f976635538e249ad2fcc5c2180b78d3c1d79c7f import { describe, it, beforeEach, mock } from 'node:test'; import { toSearchName } from '../../shared/search-name.js'; 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 { eventCandidatesForTx, eventLinkMap, linkTxEvent, linksForEvent, linksForTx, onchainAsset, syncAddressLinks, txCandidatesForEvent, unlinkTxEvent, walletTxEventMap } = await import('../../server/data/tx-links.js'); const ADRES = 'bc1qvoorbeeldadresvoortests'; let ctx; beforeEach(() => { harness.recreate(); ctx = { btc: asset('BTC', 'Bitcoin', 'crypto'), account: account('Hardware wallet'), }; }); function asset(symbol, name, kind) { harness.exports.run( `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,?,?,?,?,?)`, [symbol, name, toSearchName(name), kind, symbol, null, 8, 'unit']); return harness.exports.lastId(); } function account(naam) { harness.exports.run( `INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), 'wallet', 1, 1]); return harness.exports.lastId(); } function beweging({ direction = 'in', address = ADRES, valueSats = 100_000, txid = 'tx-1', blockTime = '2024-05-01T12:00:00' } = {}) { harness.exports.run( `INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, address, blockHeight, blockTime, createdAt) VALUES (?,'onchain',?,?,?,?,?,?,?)`, [ctx.account, txid, direction, valueSats, address, blockTime ? 840_000 : null, blockTime, '2024-05-01T12:00:00']); return harness.exports.lastId(); } function gebeurtenis({ type = 'transfer', extAddress = ADRES, amount = 100_000, occurredAt = '2024-05-01T12:00:00', assetId = null } = {}) { harness.exports.run( `INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, toAccountId, source, externalAddress, createdAt) VALUES (?,?,?,?,?,?,?,?,?)`, [assetId ?? ctx.btc, type, occurredAt, amount, type === 'transfer' ? null : 50000, ctx.account, 'manual', extAddress, '2024-05-01T12:00:00']); return harness.exports.lastId(); } const links = () => harness.exports.all( `SELECT walletTransactionId, eventId, method FROM WalletTransactionEventLink ORDER BY walletTransactionId, eventId`); describe('syncAddressLinks: dezelfde payment hash op twee lightning-wallets', () => { function bron(accountId, kind) { harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, connectionId, createdAt) VALUES (?, ?, ?, NULL, '2026-01-01')`, [accountId, kind, `${kind}-${accountId}-${Math.random()}`]); return harness.exports.lastId(); } function betaling(accountId, sourceId, hash = 'hash-zap') { harness.exports.run(`INSERT INTO WalletTransaction (accountId, sourceId, layer, txid, direction, valueSats, blockTime, createdAt) VALUES (?, ?, 'lightning', ?, 'out', 21, '2026-10-19T10:00:00Z', '2026-10-19')`, [accountId, sourceId, hash]); return harness.exports.lastId(); } const koppel = (tx, ev) => harness.exports.run(`INSERT INTO WalletTransactionEventLink (walletTransactionId, eventId, method, createdAt) VALUES (?, ?, 'manual', '2026-10-19')`, [tx, ev]); it('neemt de gebeurtenis van de tweeling over, in beide richtingen', async () => { const ev = gebeurtenis({ type: 'transfer', extAddress: null }); const lnd = betaling(ctx.account, bron(ctx.account, 'nwc')); const nwc = betaling(ctx.account, bron(ctx.account, 'nwc')); koppel(lnd, ev); assert.equal(await syncAddressLinks(), 1); assert.deepEqual(links().find(l => l.walletTransactionId === nwc), { walletTransactionId: nwc, eventId: ev, method: 'exact' }); assert.equal(await syncAddressLinks(), 0, 'de tweede keer is er niets nieuw'); }); it('koppelt niets over naar een ander account met dezelfde hash', async () => { const ander = account('Iemand anders'); const ev = gebeurtenis({ type: 'transfer', extAddress: null }); const hier = betaling(ctx.account, bron(ctx.account, 'nwc')); betaling(ander, bron(ander, 'nwc')); koppel(hier, ev); assert.equal(await syncAddressLinks(), 0); }); it('een verschillende hash of een on-chain rij met dezelfde txid is geen tweeling', async () => { const ev = gebeurtenis({ type: 'transfer', extAddress: null }); const een = betaling(ctx.account, bron(ctx.account, 'nwc'), 'hash-a'); betaling(ctx.account, bron(ctx.account, 'nwc'), 'hash-b'); beweging({ txid: 'hash-a', address: null }); koppel(een, ev); assert.equal(await syncAddressLinks(), 0); }); it('een gekoppelde on-chain rij geeft zijn gebeurtenis niet door aan een lightning-rij met dezelfde txid', async () => { const ev = gebeurtenis({ type: 'transfer', extAddress: null }); const keten = beweging({ txid: 'hash-x', address: null }); betaling(ctx.account, bron(ctx.account, 'nwc'), 'hash-x'); koppel(keten, ev); assert.equal(await syncAddressLinks(), 0); }); it('een koppeling die de gebruiker brak, komt niet terug', async () => { const ev = gebeurtenis({ type: 'transfer', extAddress: null }); const een = betaling(ctx.account, bron(ctx.account, 'nwc')); const twee = betaling(ctx.account, bron(ctx.account, 'nwc')); koppel(een, ev); harness.exports.run(`INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt) VALUES (?, ?, ?, 'hash-zap', 21, 'out', 'transfer', 'ignored', ?, '2026-10-19')`, [ctx.account, ctx.btc, twee, ev]); assert.equal(await syncAddressLinks(), 0); }); describe('wat de oude LND-booker al boekte', () => { function oudeBoeking(ref, acc = ctx.account) { harness.exports.run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fromAccountId, source, importReference, createdAt) VALUES (?, 'sell', '2026-10-19T10:00:00Z', 21, 50000, ?, 'lightning', ?, '2026-10-19')`, [ctx.btc, acc, ref]); return harness.exports.lastId(); } it('koppelt een betaling op zijn payment hash, en de NWC-tweeling neemt het over', async () => { const ev = oudeBoeking('ln:hash-zap'); const lnd = betaling(ctx.account, bron(ctx.account, 'nwc')); const nwc = betaling(ctx.account, bron(ctx.account, 'nwc')); assert.equal(await syncAddressLinks(), 2); assert.deepEqual(links().map(l => [l.walletTransactionId, l.eventId, l.method]), [[lnd, ev, 'exact'], [nwc, ev, 'exact']]); }); it('koppelt een forward op zijn moment', async () => { const ev = oudeBoeking('lnfwd:2026-10-19T10:00:00Z'); const fwd = betaling(ctx.account, bron(ctx.account, 'nwc'), 'fwd:1:2:3'); await syncAddressLinks(); assert.deepEqual(links().map(l => [l.walletTransactionId, l.eventId]), [[fwd, ev]]); }); it('koppelt niet naar een boeking van een ander account, niet met een andere bron, en niet aan een betaling op hetzelfde moment', async () => { oudeBoeking('ln:hash-zap', account('Iemand anders')); harness.exports.run(`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fromAccountId, source, importReference, createdAt) VALUES (?, 'sell', '2026-10-19T10:00:00Z', 21, 50000, ?, 'import', 'ln:hash-zap', '2026-10-19')`, [ctx.btc, ctx.account]); oudeBoeking('lnfwd:2026-10-19T10:00:00Z'); betaling(ctx.account, bron(ctx.account, 'nwc')); assert.equal(await syncAddressLinks(), 0); }); }); }); describe('syncAddressLinks: exacte match op adres', () => { it('koppelt een inkomende beweging aan een event met hetzelfde externe adres', async () => { const tx = beweging(); const ev = gebeurtenis(); const n = await syncAddressLinks(); assert.equal(n, 1, 'moet één nieuwe koppeling melden'); assert.deepEqual(links(), [{ walletTransactionId: tx, eventId: ev, method: 'exact' }]); }); it('koppelt niets bij een afwijkend adres', async () => { beweging({ address: ADRES }); gebeurtenis({ extAddress: 'bc1qeenheelanderadres' }); assert.equal(await syncAddressLinks(), 0); assert.deepEqual(links(), []); }); it('negeert uitgaande bewegingen', async () => { beweging({ direction: 'out' }); gebeurtenis(); assert.equal(await syncAddressLinks(), 0); }); it('negeert bewegingen zonder adres', async () => { beweging({ address: null }); gebeurtenis({ extAddress: null }); assert.equal(await syncAddressLinks(), 0, 'twee NULL-adressen zijn in SQL niet gelijk, en horen ook niet te matchen'); }); it('levert bij adres-hergebruik een kruisproduct, niet een 1-op-1-match', async () => { const tx1 = beweging({ txid: 'tx-1' }); const tx2 = beweging({ txid: 'tx-2' }); const ev1 = gebeurtenis(); const ev2 = gebeurtenis(); assert.equal(await syncAddressLinks(), 4); assert.deepEqual(links().map(l => [l.walletTransactionId, l.eventId]), [[tx1, ev1], [tx1, ev2], [tx2, ev1], [tx2, ev2]]); }); it('is idempotent: tweemaal draaien levert geen extra koppelingen', async () => { beweging(); gebeurtenis(); assert.equal(await syncAddressLinks(), 1); assert.equal(await syncAddressLinks(), 0, 'de tweede keer is er niets nieuw'); assert.equal(links().length, 1); }); it('laat een handmatig verbroken koppeling niet terugkomen', async () => { const tx = beweging(); const ev = gebeurtenis(); harness.exports.run( `INSERT INTO ReconcileProposal (accountId, assetId, walletTransactionId, transactionHash, amount, direction, suggestedType, status, resolvedEventId, detectedAt) VALUES (?,?,?,?,?,?,?, 'ignored', ?,?)`, [ctx.account, ctx.btc, tx, 'tx-1', 100_000, 'in', 'transfer', ev, '2024-05-02T00:00:00']); assert.equal(await syncAddressLinks(), 0); assert.deepEqual(links(), []); }); it('legt elke koppeling ook vast als afgehandelde proposal (auto_linked)', async () => { const tx = beweging(); const ev = gebeurtenis({ type: 'transfer' }); await syncAddressLinks(); const p = harness.exports.all(`SELECT * FROM ReconcileProposal`); assert.equal(p.length, 1); assert.equal(p[0].status, 'auto_linked'); assert.equal(p[0].walletTransactionId, tx); assert.equal(p[0].resolvedEventId, ev); assert.equal(p[0].suggestedType, 'transfer'); assert.equal(p[0].amount, 100_000); }); it('stelt trade voor als het event geen transfer is', async () => { beweging(); gebeurtenis({ type: 'buy' }); await syncAddressLinks(); assert.equal(harness.exports.one(`SELECT suggestedType FROM ReconcileProposal`).suggestedType, 'trade'); }); it('vraagt alleen om persist als er iets veranderd is', async () => { beweging(); gebeurtenis(); const voor = harness.calls.persist; await syncAddressLinks(); assert.ok(harness.calls.persist > voor, 'een nieuwe koppeling hoort te persisteren'); const tussen = harness.calls.persist; await syncAddressLinks(); assert.equal(harness.calls.persist, tussen, 'zonder wijziging geen schrijfactie'); }); }); describe('onchainAsset', () => { it('kiest BTC als dat bestaat', () => { asset('ETH', 'Ethereum', 'crypto'); assert.equal(onchainAsset().symbol, 'BTC'); }); it('valt terug op het eerste crypto-asset op alfabet', () => { harness.recreate(); ctx = { btc: null, account: account('Wallet') }; asset('SOL', 'Solana', 'crypto'); asset('ETH', 'Ethereum', 'crypto'); assert.equal(onchainAsset().symbol, 'ETH'); }); it('geeft null als er geen crypto-asset is', () => { harness.recreate(); ctx = { btc: null, account: account('Wallet') }; asset('XAU', 'Goud', 'metal'); assert.equal(onchainAsset(), null); }); }); describe('linkTxEvent / unlinkTxEvent', () => { it('legt een handmatige koppeling met de opgegeven methode', async () => { const tx = beweging(); const ev = gebeurtenis(); await linkTxEvent(tx, ev, 'manual'); assert.deepEqual(links(), [{ walletTransactionId: tx, eventId: ev, method: 'manual' }]); }); it('weigert een methode buiten de SSOT-enum', async () => { const tx = beweging(); const ev = gebeurtenis(); assert.ok(!ENUMS.LINK_METHOD.includes('gokje')); await assert.rejects(() => linkTxEvent(tx, ev, 'gokje'), /method/); assert.deepEqual(links(), []); }); it('negeert een dubbele koppeling in plaats van te falen', async () => { const tx = beweging(); const ev = gebeurtenis(); await linkTxEvent(tx, ev, 'manual'); await linkTxEvent(tx, ev, 'soft'); const l = links(); assert.equal(l.length, 1, 'UNIQUE(paar) + OR IGNORE'); assert.equal(l[0].method, 'manual', 'de eerste methode blijft staan'); }); it('verbreekt precies één paar', async () => { const tx = beweging(); const ev1 = gebeurtenis(); const ev2 = gebeurtenis(); await linkTxEvent(tx, ev1, 'manual'); await linkTxEvent(tx, ev2, 'manual'); await unlinkTxEvent(tx, ev1); assert.deepEqual(links().map(l => l.eventId), [ev2]); }); }); describe('eventLinkMap / walletTxEventMap', () => { it('geeft per event de gebruikte methode', async () => { const tx = beweging(); const ev = gebeurtenis(); await linkTxEvent(tx, ev, 'soft'); assert.equal(eventLinkMap().get(ev), 'soft'); }); it('groepeert meerdere events onder één beweging', async () => { const tx = beweging(); const ev1 = gebeurtenis(); const ev2 = gebeurtenis(); await linkTxEvent(tx, ev1, 'manual'); await linkTxEvent(tx, ev2, 'manual'); const rij = walletTxEventMap().get(tx); assert.equal(rij.length, 2, 'één beweging kan aan meerdere events hangen'); assert.deepEqual(rij.map(r => r.eventId).sort((a, b) => a - b), [ev1, ev2]); }); it('is leeg zonder koppelingen', () => { beweging(); gebeurtenis(); assert.equal(eventLinkMap().size, 0); assert.equal(walletTxEventMap().size, 0); }); }); describe('eventCandidatesForTx', () => { it('sorteert op datum-nabijheid tot de beweging', () => { const tx = beweging({ blockTime: '2024-05-01T12:00:00' }); const ver = gebeurtenis({ occurredAt: '2024-05-20T12:00:00' }); const dicht = gebeurtenis({ occurredAt: '2024-05-02T12:00:00' }); const ids = eventCandidatesForTx(tx, ctx.btc).map(e => e.id); assert.deepEqual(ids, [dicht, ver]); }); it('laat events weg die al aan deze beweging hangen', async () => { const tx = beweging(); const gekoppeld = gebeurtenis(); const vrij = gebeurtenis({ occurredAt: '2024-05-03T12:00:00' }); await linkTxEvent(tx, gekoppeld, 'manual'); assert.deepEqual(eventCandidatesForTx(tx, ctx.btc).map(e => e.id), [vrij]); }); it('beperkt tot het gevraagde asset', () => { const eth = asset('ETH', 'Ethereum', 'crypto'); const tx = beweging(); const btcEvent = gebeurtenis(); gebeurtenis({ assetId: eth }); assert.deepEqual(eventCandidatesForTx(tx, ctx.btc).map(e => e.id), [btcEvent]); }); it('valt terug op createdAt als de beweging geen blockTime heeft', () => { const tx = beweging({ blockTime: null }); const ver = gebeurtenis({ occurredAt: '2025-01-01T12:00:00' }); const dicht = gebeurtenis({ occurredAt: '2024-05-01T13:00:00' }); assert.deepEqual(eventCandidatesForTx(tx, ctx.btc).map(e => e.id), [dicht, ver]); }); }); describe('txCandidatesForEvent', () => { it('sorteert op datum-nabijheid tot het event', () => { const ev = gebeurtenis({ occurredAt: '2024-05-10T12:00:00' }); const ver = beweging({ txid: 'ver', blockTime: '2024-01-01T12:00:00' }); const dicht = beweging({ txid: 'dicht', blockTime: '2024-05-09T12:00:00' }); assert.deepEqual(txCandidatesForEvent(ev).map(t => t.id), [dicht, ver]); }); it('laat bewegingen weg die al aan dit event hangen', async () => { const ev = gebeurtenis(); const gekoppeld = beweging({ txid: 'a' }); const vrij = beweging({ txid: 'b', blockTime: '2024-05-05T12:00:00' }); await linkTxEvent(gekoppeld, ev, 'manual'); assert.deepEqual(txCandidatesForEvent(ev).map(t => t.id), [vrij]); }); it('toont ook uitgaande bewegingen als kandidaat', () => { const ev = gebeurtenis(); const uit = beweging({ direction: 'out', txid: 'uit' }); assert.ok(txCandidatesForEvent(ev).some(t => t.id === uit)); }); }); describe('linksForTx / linksForEvent', () => { it('geeft de tegenhangers met methode terug', async () => { const tx = beweging(); const ev = gebeurtenis({ type: 'transfer' }); await linkTxEvent(tx, ev, 'soft'); const perTx = linksForTx(tx); assert.equal(perTx.length, 1); assert.equal(perTx[0].id, ev); assert.equal(perTx[0].method, 'soft'); assert.equal(perTx[0].asset_symbol, 'BTC'); const perEvent = linksForEvent(ev); assert.equal(perEvent.length, 1); assert.equal(perEvent[0].id, tx); assert.equal(perEvent[0].address, ADRES); }); it('zet bewegingen zonder blockTime bovenaan bij linksForEvent', async () => { const ev = gebeurtenis(); const bevestigd = beweging({ txid: 'a', blockTime: '2024-05-01T12:00:00' }); const onbevestigd = beweging({ txid: 'b', blockTime: null }); await linkTxEvent(bevestigd, ev, 'manual'); await linkTxEvent(onbevestigd, ev, 'manual'); assert.deepEqual(linksForEvent(ev).map(t => t.id), [onbevestigd, bevestigd]); }); });