Files
AtreidesAssetsFree/tests/repo/metals.test.mjs
T

383 lines
17 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:e52c9e2ffe2a1f8ec8db420c958b634e2965734f817cfb40431e9184829c5b64
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';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const {
createMetalProduct, deleteMetalProduct, hasMetalAssets, listMetalAssets, listMetalProducts,
listMetalProductsForAsset, metalHoldings, updateMetalProduct,
} = await import('../../server/data/metals.js');
let goud, zilver;
beforeEach(() => {
harness.recreate();
goud = asset('XAU', 'Goud');
zilver = asset('XAG', 'Zilver');
});
function asset(symbol, name) {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?,?,?,'metal','g','g',7,'unit')`, [symbol, name, toSearchName(name)]);
return harness.exports.lastId();
}
function product({ assetId = null, name = 'Krugerrand', form = 'coin', grams = 31 } = {}) {
harness.exports.run(
`INSERT INTO MetalProduct (assetId, name, searchName, form, fineGrams) VALUES (?,?,?,?,?)`, [assetId ?? goud, name, toSearchName(name), form, grams]);
return harness.exports.lastId();
}
function event({ productId = null, type = 'buy', count = 1, fee = null, at = '2024-03-01T00:00:00',
assetId = null, amount = 310000000, fiatPrice = 2000, priceOrigin = null } = {}) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, fiatFee, productId, productCount,
priceOrigin, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,?, 'manual', ?)`,
[assetId ?? goud, type, at, amount, fiatPrice, fee, productId, productId === null ? null : count,
priceOrigin, at]);
return harness.exports.lastId();
}
const bezit = () => metalHoldings();
const regelVan = id => bezit().find(r => r.id === id) ?? null;
describe('metalHoldings: netto aantal', () => {
it('geeft niets terug zonder producten', () => {
assert.deepEqual(bezit(), []);
});
it('laat een product zonder events weg', () => {
product();
assert.deepEqual(bezit(), [], 'net_count is 0, dus HAVING sluit hem uit');
});
it('telt aankopen op en verkopen af', () => {
const p = product();
event({ productId: p, type: 'buy', count: 5 });
event({ productId: p, type: 'buy', count: 3 });
event({ productId: p, type: 'sell', count: 2 });
assert.equal(regelVan(p).net_count, 6);
});
it('laat een volledig verkochte positie verdwijnen', () => {
const p = product();
event({ productId: p, type: 'buy', count: 2 });
event({ productId: p, type: 'sell', count: 2 });
assert.equal(regelVan(p), null);
});
it('laat een negatief saldo ook weg', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1 });
event({ productId: p, type: 'sell', count: 3 });
assert.equal(regelVan(p), null);
});
it('negeert transfers en rente in het netto aantal', () => {
const p = product();
event({ productId: p, type: 'buy', count: 4 });
event({ productId: p, type: 'transfer', count: 4 });
assert.equal(regelVan(p).net_count, 4);
});
it('houdt producten gescheiden', () => {
const munt = product({ name: 'Krugerrand' });
const baar = product({ name: 'Baar 100g', form: 'bar', grams: 100 });
event({ productId: munt, count: 2 });
event({ productId: baar, count: 1 });
assert.equal(regelVan(munt).net_count, 2);
assert.equal(regelVan(baar).net_count, 1);
});
it('negeert events zonder product', () => {
const p = product();
event({ productId: p, count: 2 });
event({ productId: null, count: 99 });
assert.equal(regelVan(p).net_count, 2);
});
});
describe('metalHoldings: events zonder stuksaantal', () => {
it('kán geen event op een product zonder productCount meer maken', () => {
const p = product();
assert.throws(() => event({ productId: p, type: 'buy', count: null, fee: 500 }),
/CHECK constraint failed/);
});
it('telt een event mét stuks gewoon mee', () => {
const p = product();
event({ productId: p, type: 'buy', count: 3, fee: 60 });
const r = regelVan(p);
assert.equal(r.net_count, 3);
assert.equal(r.buy_events, 1);
assert.equal(r.buy_premium, 60);
});
it('laat een product zonder events weg', () => {
const p = product();
assert.equal(regelVan(p), null, 'net_count blijft 0 door de join-voorwaarde');
});
});
describe('metalHoldings: premie en aankoop-tellers', () => {
it('somt fiatFee over de aankopen', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, fee: 40 });
event({ productId: p, type: 'buy', count: 2, fee: 75 });
const r = regelVan(p);
assert.equal(r.buy_premium, 115);
assert.equal(r.buy_events, 2, 'per event, niet per stuk: zo rekent de pagina het gemiddelde');
assert.equal(r.buy_count, 3, 'buy_count telt wél de stuks');
});
it('rekent een ontbrekende fee als 0 in plaats van null', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, fee: null });
event({ productId: p, type: 'buy', count: 1, fee: 50 });
const r = regelVan(p);
assert.equal(r.buy_premium, 50);
assert.equal(r.buy_events, 2, 'het event telt wél mee als aankoop');
});
it('laat een verkoop de premie en de aankoop-tellers ongemoeid', () => {
const p = product();
event({ productId: p, type: 'buy', count: 3, fee: 90 });
event({ productId: p, type: 'sell', count: 1, fee: 25 });
const r = regelVan(p);
assert.equal(r.net_count, 2, 'het bezit daalt wel');
assert.equal(r.buy_premium, 90, 'de verkoopkosten horen niet bij de aankooppremie');
assert.equal(r.buy_count, 3);
assert.equal(r.buy_events, 1);
});
it('geeft 0 in plaats van null als er geen fees zijn', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, fee: null });
assert.equal(regelVan(p).buy_premium, 0);
});
});
describe('metalHoldings: de kostprijs per product', () => {
it('somt het GEWICHT en de bestede spot over de aankopen', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 2000 });
event({ productId: p, type: 'buy', count: 2, amount: 620000000, fiatPrice: 2500 });
const r = regelVan(p);
assert.equal(r.buy_amount, 930000000, 'amount en niet productCount: dit is gewicht, geen stuks');
assert.equal(r.buy_spot_raw, 310000000 * 2000 + 620000000 * 2500);
assert.equal(r.buy_spot_raw / r.buy_amount, (310000000 * 2000 + 620000000 * 2500) / 930000000);
});
it('laat een verkoop de kostprijs ongemoeid', () => {
const p = product();
event({ productId: p, type: 'buy', count: 3, amount: 930000000, fiatPrice: 2000 });
event({ productId: p, type: 'sell', count: 1, amount: 310000000, fiatPrice: 3000 });
const r = regelVan(p);
assert.equal(r.net_count, 2, 'het bezit daalt wel');
assert.equal(r.buy_amount, 930000000);
assert.equal(r.buy_spot_raw, 930000000 * 2000);
});
it('geeft 0 en geen null als er niets gekocht is', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1 });
const r = regelVan(p);
assert.equal(typeof r.buy_amount, 'number');
assert.equal(typeof r.buy_spot_raw, 'number');
});
it('laat een gekregen munt buiten de kostprijs en telt hem wel in het bezit', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 2000, fee: 40 });
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 0, priceOrigin: 'none' });
const r = regelVan(p);
assert.equal(r.net_count, 2, 'twee munten in bezit');
assert.equal(r.buy_amount, 310000000, 'maar één telt voor de kostprijs');
assert.equal(r.buy_spot_raw, 310000000 * 2000);
assert.equal(r.buy_events, 1);
assert.equal(r.buy_premium, 40, 'aan een gift is geen premie betaald');
assert.equal(r.gift_amount, 310000000, 'en het gewicht ervan gaat mee, voor de kostprijs nul');
});
it('houdt een munt met ONBEKENDE herkomst in de kostprijs', () => {
const p = product();
event({ productId: p, type: 'buy', count: 1, amount: 310000000, fiatPrice: 2000 });
const r = regelVan(p);
assert.equal(r.buy_amount, 310000000);
assert.equal(r.gift_amount, 0);
});
});
describe('metalHoldings: resultaatvorm', () => {
it('verrijkt met de asset-gegevens', () => {
const p = product({ name: 'Maple Leaf', form: 'coin', grams: 31 });
event({ productId: p, count: 1 });
const r = regelVan(p);
assert.equal(r.assetId, goud);
assert.equal(r.asset_symbol, 'XAU');
assert.equal(r.asset_name, 'Goud');
assert.equal(r.name, 'Maple Leaf');
assert.equal(r.form, 'coin');
assert.equal(r.fineGrams, 31, 'gewicht komt uit het product; de waarde rekent de pagina met de spot');
});
it('sorteert op asset, dan vorm, dan gewicht', () => {
const zilverBaar = product({ assetId: zilver, name: 'Zilverbaar', form: 'bar', grams: 1000 });
const goudBaarKlein = product({ name: 'Goudbaar 10g', form: 'bar', grams: 10 });
const goudBaarGroot = product({ name: 'Goudbaar 100g', form: 'bar', grams: 100 });
const goudMunt = product({ name: 'Krugerrand', form: 'coin', grams: 31 });
for (const p of [zilverBaar, goudBaarKlein, goudBaarGroot, goudMunt]) {
event({ productId: p, count: 1, assetId: p === zilverBaar ? zilver : goud });
}
assert.deepEqual(bezit().map(r => r.id), [zilverBaar, goudBaarKlein, goudBaarGroot, goudMunt]);
});
});
describe('hasMetalAssets en listMetalAssets', () => {
it('ziet metaal-assets en negeert crypto', () => {
assert.equal(hasMetalAssets(), true);
assert.deepEqual(listMetalAssets().map(a => a.symbol), ['XAG', 'XAU'], 'gesorteerd op symbool');
harness.exports.run(`DELETE FROM Asset`);
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
assert.equal(hasMetalAssets(), false);
assert.deepEqual(listMetalAssets(), []);
});
});
describe('listMetalProducts en listMetalProductsForAsset', () => {
it('geeft alle producten met asset-gegevens, gesorteerd als het bezit', () => {
const munt = product({ name: 'Krugerrand', form: 'coin', grams: 31 });
const baar = product({ name: 'Goudbaar', form: 'bar', grams: 100 });
const zilverBaar = product({ assetId: zilver, name: 'Zilverbaar', form: 'bar', grams: 1000 });
const alle = listMetalProducts();
assert.deepEqual(alle.map(p => p.id), [zilverBaar, baar, munt],
'zelfde ordening als metalHoldings: asset, vorm, gewicht');
assert.equal(alle[0].asset_symbol, 'XAG');
assert.equal(alle[0].asset_name, 'Zilver');
});
it('geeft producten van één asset zonder de asset-join', () => {
product({ name: 'Krugerrand', form: 'coin', grams: 31 });
product({ assetId: zilver, name: 'Zilverbaar', form: 'bar', grams: 1000 });
const vanGoud = listMetalProductsForAsset(goud);
assert.deepEqual(vanGoud.map(p => p.name), ['Krugerrand']);
assert.equal(vanGoud[0].asset_symbol, undefined, 'geen join: de aanroeper kent het asset al');
});
it('toont ook een product zonder enige aankoop', () => {
const p = product({ name: 'Nog nooit gekocht', form: 'coin', grams: 8 });
assert.equal(listMetalProducts().some(x => x.id === p), true);
assert.equal(bezit().some(x => x.id === p), false);
});
it('is leeg zonder producten', () => {
assert.deepEqual(listMetalProducts(), []);
assert.deepEqual(listMetalProductsForAsset(goud), []);
});
});
describe('createMetalProduct', () => {
const geldig = (extra = {}) => ({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 311035, ...extra });
it('bewaart de velden en geeft het id terug', async () => {
const id = await createMetalProduct(geldig());
const p = harness.exports.one(`SELECT * FROM MetalProduct WHERE id = ?`, [id]);
assert.equal(p.assetId, goud);
assert.equal(p.name, 'Krugerrand');
assert.equal(p.form, 'coin');
assert.equal(p.fineGrams, 311035, 'fijn gewicht in de gram-basis, niet in troy ounce');
});
it('trimt de naam', async () => {
const id = await createMetalProduct(geldig({ name: ' Maple Leaf ' }));
assert.equal(harness.exports.one(`SELECT name FROM MetalProduct WHERE id = ?`, [id]).name, 'Maple Leaf');
});
it('weigert een naam die alleen uit witruimte bestaat', async () => {
await assert.rejects(() => createMetalProduct(geldig({ name: ' ' })), (e) => e.key === 'metalProducts.nameRequired');
});
it('weigert een onbekende vorm', async () => {
await assert.rejects(() => createMetalProduct(geldig({ form: 'ketting' })), /form/);
});
it('weigert een gewicht van nul of minder, en een gebroken getal', async () => {
await assert.rejects(() => createMetalProduct(geldig({ fineGrams: 0 })), (e) => e.key === 'metalProducts.fineGramsPositive');
await assert.rejects(() => createMetalProduct(geldig({ fineGrams: -5 })), (e) => e.key === 'metalProducts.fineGramsPositive');
await assert.rejects(() => createMetalProduct(geldig({ fineGrams: 31.5 })), (e) => e.key === 'metalProducts.fineGramsPositive');
});
it('weigert een asset dat geen metaal is', async () => {
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'subunit')`);
const btc = harness.exports.lastId();
await assert.rejects(() => createMetalProduct(geldig({ assetId: btc })), (e) => e.key === 'metalProducts.notMetal');
});
it('weigert een asset dat niet bestaat', async () => {
await assert.rejects(() => createMetalProduct(geldig({ assetId: 9999 })), (e) => e.key === 'metalProducts.notMetal');
await assert.rejects(() => createMetalProduct(geldig({ assetId: null })), (e) => e.key === 'metalProducts.assetRequired');
});
it('persisteert', async () => {
const voor = harness.calls.persist;
await createMetalProduct(geldig());
assert.equal(harness.calls.persist, voor + 1);
});
});
describe('updateMetalProduct', () => {
it('overschrijft alle velden', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Oud', form: 'coin', fineGrams: 31 });
await updateMetalProduct(id, { assetId: zilver, name: 'Nieuw', form: 'bar', fineGrams: 1000 });
const p = harness.exports.one(`SELECT * FROM MetalProduct WHERE id = ?`, [id]);
assert.equal(p.assetId, zilver);
assert.equal(p.name, 'Nieuw');
assert.equal(p.form, 'bar');
assert.equal(p.fineGrams, 1000);
});
it('valideert met dezelfde regels als aanmaken', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
await assert.rejects(() => updateMetalProduct(id, { assetId: goud, name: '', form: 'coin', fineGrams: 31 }), (e) => e.key === 'metalProducts.nameRequired');
await assert.rejects(() => updateMetalProduct(id, { assetId: goud, name: 'X', form: 'ring', fineGrams: 31 }), /form/);
await assert.rejects(() => updateMetalProduct(id, { assetId: goud, name: 'X', form: 'coin', fineGrams: 0 }), (e) => e.key === 'metalProducts.fineGramsPositive');
assert.equal(harness.exports.one(`SELECT name FROM MetalProduct WHERE id = ?`, [id]).name, 'Krugerrand',
'de bestaande rij blijft ongemoeid als de validatie afketst');
});
});
describe('deleteMetalProduct', () => {
it('verwijdert een product dat nergens in gebruik is', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
await deleteMetalProduct(id);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM MetalProduct`).n, 0);
});
it('weigert zolang er een gebeurtenis naar verwijst', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
event({ productId: id, count: 2 });
await assert.rejects(() => deleteMetalProduct(id), (e) => e.key === 'metalProducts.inUse' && e.params.n === 1);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM MetalProduct`).n, 1);
});
it('noemt het aantal gebeurtenissen in de melding', async () => {
const id = await createMetalProduct({ assetId: goud, name: 'Krugerrand', form: 'coin', fineGrams: 31 });
event({ productId: id, count: 1 });
event({ productId: id, count: 1, at: '2024-04-01T00:00:00' });
await assert.rejects(() => deleteMetalProduct(id), (e) => e.key === 'metalProducts.inUse' && e.params.n === 2);
});
it('laat andere producten staan', async () => {
const een = await createMetalProduct({ assetId: goud, name: 'Een', form: 'coin', fineGrams: 31 });
const twee = await createMetalProduct({ assetId: goud, name: 'Twee', form: 'bar', fineGrams: 100 });
await deleteMetalProduct(een);
assert.deepEqual(listMetalProducts().map(p => p.id), [twee]);
});
});