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