Files
2026-10-29 11:57:29 +01:00

413 lines
17 KiB
JavaScript

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