Files
AtreidesAssetsFree/tests/repo/merge.test.mjs
T
2026-10-29 11:57:29 +01:00

303 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generated, do not edit. sha256:ba6482c6f8221f445c935cfd65e76c09e943da662ff5dab04e50d8a76e004316
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 { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { mergeAccounts, mergeCheck } = await import('../../server/data/accounts-merge.js');
const { eventsNetByAccount } = await import('../../server/data/events.js');
let btc;
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,'unit')`);
btc = harness.exports.lastId();
});
function account(naam, kind = 'wallet') {
const eigen = ACCOUNT_SOURCE_KINDS[kind].length > 0 ? 1 : 0;
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), kind, eigen, 0]);
return harness.exports.lastId();
}
function event({ type = 'buy', from = null, to = null, amount = 1000, at = '2024-01-01T00:00:00', unitFee = null }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,'manual',?)`,
[btc, type, at, amount, type === 'buy' || type === 'sell' ? 1 : null, unitFee, from, to, at]);
return harness.exports.lastId();
}
function waardering({ acc, onDate, balance, rateAt = null }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?, 'manual')`,
[btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]);
return harness.exports.lastId();
}
const tel = (tabel, kolom, id) =>
harness.exports.one(`SELECT COUNT(*) AS n FROM ${tabel} WHERE ${kolom} = ?`, [id]).n;
const bestaatAccount = id => harness.exports.one(`SELECT 1 AS x FROM Account WHERE id=?`, [id]) != null;
describe('mergeCheck: compatibiliteit', () => {
it('vraagt om twee accounts', () => {
const a = account('A');
assert.equal(mergeCheck(a, null).reason, 'pickTwo');
assert.equal(mergeCheck(null, a).reason, 'pickTwo');
assert.equal(mergeCheck(a, null).counts, null);
});
it('weigert een account met zichzelf', () => {
const a = account('A');
assert.equal(mergeCheck(a, a).reason, 'sameAccount');
assert.equal(mergeCheck(String(a), a).reason, 'sameAccount',
'ook als het id als string binnenkomt (uit een <select>)');
});
it('weigert een onbekend account', () => {
const a = account('A');
assert.equal(mergeCheck(a, 9999).reason, 'notFound');
});
it('weigert custodial × trackable', () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
assert.equal(mergeCheck(beurs, wallet).reason, 'sourceMismatch');
assert.equal(mergeCheck(wallet, beurs).reason, 'sourceMismatch',
'de regel geldt in beide richtingen');
});
it('weigert een overlever die de bron-soorten van de opheffer niet kan dragen', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(wallet, node).reason, 'kindCannotCarry');
});
it('staat de andere richting wél toe', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(node, wallet).ok, true,
'lightning kan alles dragen wat wallet kan');
});
it('staat twee custodial soorten toe', () => {
const bank = account('Bank', 'bank');
const kluis = account('Kluis', 'vault');
assert.equal(mergeCheck(bank, kluis).ok, true);
});
it('telt wat er zou verhuizen, en kijkt naar beide event-kanten', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
event({ type: 'buy', to: s });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qtest', '2024-01-01T00:00:00')`, [v]);
waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
const chk = mergeCheck(s, v);
assert.equal(chk.ok, true);
assert.deepEqual(chk.counts, { sources: 1, events: 2, schedules: 0, valuations: 1 });
assert.equal(chk.survivorName, 'S');
assert.equal(chk.victimName, 'V');
});
it('verandert niets aan de database', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
mergeCheck(s, v);
assert.equal(tel('Event', 'toAccountId', v), 1, 'een preview hoort niets te muteren');
assert.ok(bestaatAccount(v));
});
});
describe('mergeAccounts: verhuizing van kinddata', () => {
it('weigert een incompatibele merge en laat alles staan', async () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
event({ type: 'buy', to: wallet });
await assert.rejects(() => mergeAccounts(beurs, wallet), (e) => e.key === 'accounts.mergeReason_sourceMismatch');
assert.ok(bestaatAccount(wallet), 'de opheffer bestaat nog');
assert.equal(tel('Event', 'toAccountId', wallet), 1);
});
it('verhuist alle acht soorten kinddata en verwijdert de opheffer', async () => {
const s = account('Overlever');
const v = account('Opheffer');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO WalletAddress (accountId, address) VALUES (?, 'bc1qadres')`, [v]);
harness.exports.run(`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, createdAt)
VALUES (?, 'onchain', 'tx1', 'in', 100, '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO ScanLog (accountId, scannedAt, status)
VALUES (?, '2024-01-01T00:00:00', 'success')`, [v]);
harness.exports.run(`INSERT INTO ReconcileProposal
(accountId, assetId, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, 100, 'in', 'transfer', 'pending', '2024-01-01T00:00:00')`, [v, btc]);
harness.exports.run(`INSERT INTO ImportBatch (accountId, filename, importedAt, rowCount)
VALUES (?, 'x.csv', '2024-01-01T00:00:00', 3)`, [v]);
harness.exports.run(`INSERT INTO InterestSchedule
(accountId, rateKind, payoutAssetId, startsOn, compounding, createdAt)
VALUES (?, 'variable', ?, '2024-01-01', 0, '2024-01-01T00:00:00')`, [v, btc]);
const counts = await mergeAccounts(s, v);
assert.equal(tel('Event', 'toAccountId', s), 1);
assert.equal(tel('Event', 'fromAccountId', s), 1);
assert.equal(tel('WalletSource', 'accountId', s), 1);
assert.equal(tel('WalletAddress', 'accountId', s), 1);
assert.equal(tel('WalletTransaction', 'accountId', s), 1);
assert.equal(tel('ScanLog', 'accountId', s), 1);
assert.equal(tel('ReconcileProposal', 'accountId', s), 1);
assert.equal(tel('ImportBatch', 'accountId', s), 1);
assert.equal(tel('InterestSchedule', 'accountId', s), 1);
for (const [tabel, kolom] of [['Event', 'toAccountId'], ['Event', 'fromAccountId'],
['WalletSource', 'accountId'], ['WalletAddress', 'accountId'], ['WalletTransaction', 'accountId'],
['ScanLog', 'accountId'], ['ReconcileProposal', 'accountId'], ['ImportBatch', 'accountId'],
['InterestSchedule', 'accountId']]) {
assert.equal(tel(tabel, kolom, v), 0, `${tabel}.${kolom} verwijst nog naar de opheffer`);
}
assert.equal(bestaatAccount(v), false, 'de opheffer hoort verdwenen te zijn');
assert.ok(bestaatAccount(s));
assert.equal(counts.events, 2, 'de teruggegeven counts komen uit de voorbeschouwing');
});
it("laat de bron-id's ongemoeid, zodat NWC-secrets geldig blijven", async () => {
const s = account('S');
const v = account('V');
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
const bronId = harness.exports.lastId();
await mergeAccounts(s, v);
const bron = harness.exports.one(`SELECT id, accountId FROM WalletSource`);
assert.equal(bron.id, bronId, 'het bron-id mag niet wijzigen');
assert.equal(bron.accountId, s);
});
});
describe('mergeAccounts: waarderingen', () => {
const verschil = (valId) => {
const v = harness.exports.one(`SELECT accountId, assetId, rateAt, balance FROM Valuation WHERE id=?`, [valId]);
return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt);
};
it('houdt het verschil van elke waardering gelijk', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
assert.equal(verschil(valV), 200);
assert.equal(verschil(valS), 0);
await mergeAccounts(s, v);
assert.equal(verschil(valV), 200, 'een bestaand verschil moet behouden blijven');
assert.equal(verschil(valS), 0, 'reconciled (0) moet 0 blijven');
});
it('corrigeert de balance daarvoor, ook van de overlever zelf', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
await mergeAccounts(s, v);
const balans = id => harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [id]).balance;
assert.equal(balans(valV), 1700, '1500 grootboek + 200 verschil');
assert.equal(balans(valS), 1500, '1500 grootboek + 0 verschil, dus herschreven van 500');
});
it('verhuist een niet-botsende waardering naar de overlever', async () => {
const s = account('S');
const v = account('V');
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 10 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT accountId FROM Valuation WHERE id=?`, [val]).accountId, s);
});
it('houdt bij een botsing precies één waardering over', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
const over = harness.exports.all(`SELECT accountId, balance FROM Valuation`);
assert.equal(over.length, 1, 'geen dubbele rij, geen UNIQUE-fout');
assert.equal(over[0].accountId, s, 'de overgebleven rij hangt aan de overlever');
});
it('wist bij een botsing de rij van de opheffer, niet die van de overlever', async () => {
const s = account('S');
const v = account('V');
const valS = waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
assert.ok(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valS]),
'de waardering van de overlever blijft');
assert.equal(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valV]), null,
'de botsende rij van de opheffer wijkt');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation`).balance, 500,
'en dus blijft de gemeten balans van het behouden account staan');
});
it('botst alleen op dezelfde datum, niet op hetzelfde asset alleen', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-02', balance: 1200 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Valuation`).n, 2);
});
it('rekent het verschil met transfers en fees mee', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'transfer', from: v, to: null, amount: 300, unitFee: 20, at: '2024-01-15T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 700 });
assert.equal(verschil(val), 20);
await mergeAccounts(s, v);
assert.equal(verschil(val), 20);
});
it('negeert waarderingen ná het rateAt-moment bij de correctie', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 9999, at: '2024-06-01T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
assert.equal(verschil(val), 0);
await mergeAccounts(s, v);
assert.equal(verschil(val), 0, 'de late aankoop hoort buiten dit venster te vallen');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [val]).balance, 1000,
'balance blijft 1000: het grootboek t/m die datum is niet veranderd');
});
});