Files
AtreidesAssetsFree/tests/repo/wallet-txs.test.mjs
T

429 lines
21 KiB
JavaScript
Raw Normal View History

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