384 lines
17 KiB
JavaScript
384 lines
17 KiB
JavaScript
// 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]);
|
|
});
|
|
});
|