// Generated, do not edit. sha256:ac2e17a56171cfd2df7cbf0535002e994a9b104a37a2b866dda8f2d218d16c98 import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { createDbHarness } from '../helpers/db-node.mjs'; import { AppError } from '../../shared/app-error.js'; import { CONFIG } from '../../shared/config.js'; import { isRetryLater } from '../../server/market-service.mjs'; const harness = createDbHarness({ seedPreferences: true }); mock.module('../../server/db.js', { namedExports: harness.exports }); const nep = { configured: true, register: [], series: null, spots: null, history: null, calls: [], spotCalls: [], historyCalls: [] }; mock.module('../../server/market-service.mjs', { namedExports: { isRetryLater, marketConfigured: async () => nep.configured, marketRegister: async () => (typeof nep.register === 'function' ? nep.register() : nep.register), marketSeries: async (opts) => { nep.calls.push(opts); return nep.series(opts); }, marketHistory: async (opts) => { nep.historyCalls.push(opts); return nep.history(opts); }, marketSpots: async (keys, quote) => { nep.spotCalls.push({ keys, quote }); return nep.spots(keys, quote); }, }, }); const { intradaySeries, serviceSpot, serviceDayHistory, priceAtMoment } = await import('../../server/data/market.js'); const BTC_REG = { key: 'crypto:BTC', kind: 'crypto', symbol: 'BTC', unit: null, kinds: ['spot', 'history'], resolutions: ['minute', 'hour', 'day'] }; const XAU_REG = { key: 'metal:XAU', kind: 'metal', symbol: 'XAU', unit: 'ozt', kinds: ['spot', 'history'], resolutions: ['minute', 'day'] }; const ETH_DAG = { key: 'crypto:ETH', kind: 'crypto', symbol: 'ETH', unit: null, kinds: ['spot'], resolutions: ['day'] }; function asset(symbol, kind) { harness.exports.run( `INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES (?, ?, ?, ?, ?, NULL, 8, 'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]); return harness.exports.one(`SELECT id FROM Asset WHERE symbol = ?`, [symbol]).id; } const sinds = () => Date.now() - 3600_000; beforeEach(() => { harness.recreate(); nep.configured = true; nep.register = [BTC_REG, XAU_REG, ETH_DAG]; nep.calls = []; nep.spotCalls = []; nep.historyCalls = []; nep.history = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'eulerpool', points: [] }); nep.spots = async () => ({ spot: [{ instrument: 'crypto:BTC', price: 61000, currency: 'EUR', observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' }], missing: ['crypto:ETH'], unreachable: [], }); nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute', points: [[o.from + 60_000, 61000]] }); }); describe('intradaySeries: wat er naar de dienst gaat', () => { it('vraagt het instrument op soort en symbool, in de rekenvaluta, en niets over het asset', async () => { const id = asset('BTC', 'crypto'); const from = sinds(); const uit = await intradaySeries(id, from); assert.equal(uit.status, 'ok'); assert.equal(nep.calls.length, 1); const vraag = nep.calls[0]; assert.deepEqual(Object.keys(vraag).sort(), ['from', 'instrument', 'quote', 'resolution', 'to']); assert.equal(vraag.instrument, 'crypto:BTC'); assert.equal(vraag.quote, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY); assert.equal(vraag.resolution, 'minute'); assert.equal(vraag.from, from); assert.equal(Object.values(vraag).includes(id), false); }); it('geeft de munt van de BRON door en niet de gevraagde', async () => { const id = asset('BTC', 'crypto'); nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'x', resolution: 'minute', points: [[o.from + 1, 66000]] }); const uit = await intradaySeries(id, sinds()); assert.equal(uit.currency, 'USD'); }); it('geeft de bron per punt door, ook na het omrekenen naar gram', async () => { const id = asset('XAU', 'metal'); nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'goldapi', resolution: 'minute', points: [[o.from + 1, 2488.278144, 'goldapi'], [o.from + 2, 2488.278144]] }); const uit = await intradaySeries(id, sinds()); assert.equal(uit.points[0][2], 'goldapi'); assert.equal(uit.points[1].length, 2); assert.ok(Math.abs(uit.points[0][1] - 80) < 1e-9); }); it('noemt een punt bij de naam die de dienst geeft, en zonder naam bij de sleutel', async () => { const id = asset('BTC', 'crypto'); nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', sourceLabel: 'Een Nederlandse aanbieder', resolution: 'minute', sourceLabels: { bitvavo: 'Een Nederlandse aanbieder' }, points: [[o.from + 1, 61000, 'bitvavo'], [o.from + 2, 61010, 'mempool']] }); const uit = await intradaySeries(id, sinds()); assert.deepEqual(uit.points.map(p => p[2]), ['Een Nederlandse aanbieder', 'mempool']); assert.equal(uit.sourceLabel, 'Een Nederlandse aanbieder'); }); }); describe('priceAtMoment', () => { const AT = Date.parse('2026-10-19T10:00:00Z'); const W = CONFIG.MARKET_SERVICE.MOMENT_WINDOW_MS; it('vraagt de minuten rond het moment, zonder asset-id, en kiest het dichtstbijzijnde punt', async () => { const id = asset('BTC', 'crypto'); nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute', points: [[AT - 240_000, 60_000], [AT + 60_000, 61_000], [AT + 180_000, 62_000]] }); const uit = await priceAtMoment(id, new Date(AT).toISOString()); assert.deepEqual(uit, { status: 'ok', price: 61_000, currency: 'EUR', at: AT + 60_000, source: 'bitvavo' }); const vraag = nep.calls[0]; assert.deepEqual([vraag.instrument, vraag.resolution, vraag.from, vraag.to], ['crypto:BTC', 'minute', AT - W, AT + W]); assert.equal(Object.values(vraag).includes(id), false); }); it('een punt buiten het venster is geen koers van dat moment', async () => { const id = asset('BTC', 'crypto'); nep.series = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', resolution: 'minute', points: [[AT - W - 1, 60_000]] }); assert.deepEqual(await priceAtMoment(id, new Date(AT).toISOString()), { status: 'missing' }); }); it('een metaal per gram, en de bron van het punt als de dienst die noemt', async () => { const id = asset('XAU', 'metal'); nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'x', resolution: 'minute', points: [[AT, 2488.278144, 'goldapi']] }); const uit = await priceAtMoment(id, new Date(AT).toISOString()); assert.ok(Math.abs(uit.price - 80) < 1e-9); assert.equal(uit.source, 'goldapi'); }); it('zonder token, buiten het register of zonder minuutreeks: niets gevraagd', async () => { const btc = asset('BTC', 'crypto'); const eth = asset('ETH', 'crypto'); nep.configured = false; assert.deepEqual(await priceAtMoment(btc, new Date(AT).toISOString()), { status: 'off' }); nep.configured = true; assert.deepEqual(await priceAtMoment(eth, new Date(AT).toISOString()), { status: 'notCovered' }); assert.equal(nep.calls.length, 0); }); it('onbereikbaar is een antwoord, en een moment in de toekomst een contractfout', async () => { const id = asset('BTC', 'crypto'); nep.series = async () => { throw new AppError('marketService.unreachable', null, 'down'); }; const uit = await priceAtMoment(id, new Date(AT).toISOString()); assert.equal(uit.status, 'unavailable'); await assert.rejects(priceAtMoment(id, new Date(Date.now() + 60_000).toISOString()), /moment in the past/); await assert.rejects(priceAtMoment(id, 'gisteren'), /moment in the past/); }); }); describe('intradaySeries: wanneer er niets gevraagd wordt', () => { it('zonder token: uit, zonder melding en zonder vraag aan de dienst', async () => { const id = asset('BTC', 'crypto'); nep.configured = false; assert.deepEqual(await intradaySeries(id, sinds()), { status: 'off' }); assert.equal(nep.calls.length, 0); }); it('een asset dat het register niet kent: niet gedekt', async () => { const id = asset('DOGE', 'crypto'); assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' }); assert.equal(nep.calls.length, 0); }); it('een metaal in troy ounce: gedekt, en per gram, want zo bewaart de app metaal', async () => { const id = asset('XAU', 'metal'); nep.series = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'goldapi', resolution: 'minute', points: [[o.from + 60_000, 2488.278144]] }); const uit = await intradaySeries(id, sinds()); assert.equal(uit.status, 'ok'); assert.equal(nep.calls[0].instrument, 'metal:XAU'); assert.ok(Math.abs(uit.points[0][1] - 80) < 1e-9, `${uit.points[0][1]} per gram`); }); it('een eenheid die de app niet kent: niet gedekt, want een verkeerde factor is een stil verkeerde koers', async () => { nep.register = [{ key: 'metal:XPT', kind: 'metal', symbol: 'XPT', unit: 'lb', resolutions: ['minute'] }]; assert.deepEqual(await intradaySeries(asset('XPT', 'metal'), sinds()), { status: 'notCovered' }); assert.equal(nep.calls.length, 0); }); it('een instrument zonder minuutreeks: niet gedekt', async () => { const id = asset('ETH', 'crypto'); assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' }); }); it('dezelfde code bij een andere soort is een ander instrument', async () => { const id = asset('BTC', 'fiat'); assert.deepEqual(await intradaySeries(id, sinds()), { status: 'notCovered' }); }); }); describe('intradaySeries: een onbereikbare dienst is een antwoord, geen fout', () => { const metFout = (e) => { nep.series = async () => { throw e; }; }; it('api.monthUnreachable: onbeschikbaar en later opnieuw, met de maand', async () => { const id = asset('BTC', 'crypto'); metFout(new AppError('marketService.monthUnreachable', { month: '2026-09' }, 'x')); assert.deepEqual(await intradaySeries(id, sinds()), { status: 'unavailable', retryLater: true, key: 'marketService.monthUnreachable', params: { month: '2026-09' } }); }); it('dienst weg: onbeschikbaar en later opnieuw', async () => { const id = asset('BTC', 'crypto'); metFout(new AppError('marketService.unreachable', { url: 'u', reason: 'ECONNREFUSED' }, 'x')); const uit = await intradaySeries(id, sinds()); assert.equal(uit.status, 'unavailable'); assert.equal(uit.retryLater, true); }); it('401: onbeschikbaar maar NIET later opnieuw, zodat de melding om een nieuw token vraagt', async () => { const id = asset('BTC', 'crypto'); metFout(new AppError('marketService.unauthorized', null, 'x')); const uit = await intradaySeries(id, sinds()); assert.equal(uit.status, 'unavailable'); assert.equal(uit.retryLater, false); assert.equal(uit.key, 'marketService.unauthorized'); }); it('geeft de request-id van de mislukte aanroep mee, zodat de fout in beide logs te vinden is', async () => { const id = asset('BTC', 'crypto'); const e = new AppError('marketService.rejected', { status: 404, key: 'api.noSeries', reason: 'x' }, 'x'); e.requestId = 'a1b2c3d4-0000-4000-8000-000000000001'; metFout(e); assert.equal((await intradaySeries(id, sinds())).requestId, 'a1b2c3d4-0000-4000-8000-000000000001'); }); it('een fout uit het register telt net zo', async () => { const id = asset('BTC', 'crypto'); nep.register = () => { throw new AppError('marketService.timeout', { s: 10 }, 'x'); }; const uit = await intradaySeries(id, sinds()); assert.equal(uit.status, 'unavailable'); assert.equal(uit.retryLater, true); assert.equal(nep.calls.length, 0); }); it('een andere fout gaat gewoon door, want dat is een fout in de app', async () => { const id = asset('BTC', 'crypto'); metFout(new TypeError('stuk')); await assert.rejects(() => intradaySeries(id, sinds()), /stuk/); }); }); describe('intradaySeries: contractfouten van de app zelf', () => { it('weigert een onbekend asset', async () => { await assert.rejects(() => intradaySeries(9999, sinds()), /Unknown asset/); }); it('weigert een begin in de toekomst, geen getal, of verder terug dan de grens', async () => { const id = asset('BTC', 'crypto'); const grens = CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS * 86_400_000; for (const slecht of [Date.now() + 60_000, 'gisteren', NaN, Date.now() - grens - 60_000]) { await assert.rejects(() => intradaySeries(id, slecht), /sinceMs/, `sinceMs ${slecht}`); } assert.equal(nep.calls.length, 0); }); }); describe('serviceSpot: de actuele koers uit de dienst (fase 3)', () => { it('vraagt het HELE bruikbare register als pakket, in de rekenvaluta, en geeft de koers in de munt van de bron', async () => { const id = asset('BTC', 'crypto'); const uit = await serviceSpot(id); assert.deepEqual(uit, { status: 'ok', instrument: 'crypto:BTC', price: 61000, currency: 'EUR', observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' }); assert.equal(nep.spotCalls.length, 1); assert.deepEqual([...nep.spotCalls[0].keys].sort(), ['crypto:BTC', 'crypto:ETH', 'metal:XAU']); assert.equal(nep.spotCalls[0].quote, CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY); }); it('zonder token: uit, en geen vraag', async () => { const id = asset('BTC', 'crypto'); nep.configured = false; assert.deepEqual(await serviceSpot(id), { status: 'off' }); assert.equal(nep.spotCalls.length, 0); }); it('een handmatige koers van vandaag gaat voor: de dienst wordt niet gevraagd', async () => { const id = asset('BTC', 'crypto'); harness.exports.run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt) VALUES ('manual','Handmatig','handmatig','','none',0,1,1,1,'2026-01-01')`); const manual = harness.exports.one(`SELECT id FROM Provider WHERE key = 'manual'`).id; const gisteren = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); harness.exports.run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 50000, 'day', ?)`, [id, manual, `${gisteren}T00:00:00Z`]); assert.equal((await serviceSpot(id)).status, 'ok'); const vandaag = new Date().toISOString().slice(0, 10); harness.exports.run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 50000, 'day', ?)`, [id, manual, `${vandaag}T00:00:00Z`]); const voor = nep.spotCalls.length; assert.deepEqual(await serviceSpot(id), { status: 'local' }); assert.equal(nep.spotCalls.length, voor); }); it('niet in het register: niet gedekt, zonder vraag om koersen', async () => { assert.deepEqual(await serviceSpot(asset('DOGE', 'crypto')), { status: 'notCovered' }); assert.equal(nep.spotCalls.length, 0); }); it('een metaal per troy ounce komt per gram terug, in de munt van de bron', async () => { nep.spots = async () => ({ spot: [{ instrument: 'metal:XAU', price: 2488.278144, currency: 'USD', observedAt: 1, source: 'goldapi', sourceLabel: 'GoldAPI' }], missing: [], unreachable: [] }); const uit = await serviceSpot(asset('XAU', 'metal')); assert.equal(uit.status, 'ok'); assert.equal(uit.currency, 'USD'); assert.ok(Math.abs(uit.price - 80) < 1e-9, `${uit.price} per gram`); }); it('`missing` is een gat, `unreachable` is later opnieuw', async () => { assert.deepEqual(await serviceSpot(asset('ETH', 'crypto')), { status: 'missing' }); nep.spots = async () => ({ spot: [], missing: [], unreachable: ['crypto:BTC'] }); assert.deepEqual(await serviceSpot(asset('BTC', 'crypto')), { status: 'unavailable', retryLater: true, key: 'marketService.spotUnreachable', params: null }); }); it('dienst weg of token geweigerd: een antwoord, met het oordeel over later opnieuw', async () => { const id = asset('BTC', 'crypto'); nep.spots = async () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'ECONNREFUSED' }, 'x'); }; assert.equal((await serviceSpot(id)).retryLater, true); nep.spots = async () => { throw new AppError('marketService.unauthorized', null, 'x'); }; const uit = await serviceSpot(id); assert.equal(uit.status, 'unavailable'); assert.equal(uit.retryLater, false); }); it('weigert een onbekend asset', async () => { await assert.rejects(() => serviceSpot(9999), /Unknown asset/); }); }); describe('serviceCoverage: welke assets de dienst dekt', () => { it('geeft de assets waarvan het register het instrument kent, metaal in troy ounce inbegrepen', async () => { const { serviceCoverage } = await import('../../server/data/market.js'); const b = asset('BTC', 'crypto'), e = asset('ETH', 'crypto'); const x = asset('XAU', 'metal'); asset('DOGE', 'crypto'); assert.deepEqual(await serviceCoverage(), { status: 'ok', assetIds: [b, e, x] }); }); it('zonder token: uit, en het register wordt niet gevraagd', async () => { const { serviceCoverage } = await import('../../server/data/market.js'); nep.configured = false; let gevraagd = false; nep.register = () => { gevraagd = true; return []; }; assert.deepEqual(await serviceCoverage(), { status: 'off' }); assert.equal(gevraagd, false); }); it('register onbereikbaar: onbeschikbaar, geen fout', async () => { const { serviceCoverage } = await import('../../server/data/market.js'); nep.register = () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'x' }, 'x'); }; assert.equal((await serviceCoverage()).status, 'unavailable'); }); }); describe('serviceDayHistory: dagkoersen uit de dienst (10-10-2026)', () => { const DAG = 86_400_000; const vandaag = () => Math.floor(Date.now() / DAG) * DAG; const sinds = () => vandaag() - 10 * DAG; it('geeft alleen afgesloten dagen, geen momenten en niet vandaag, in de munt van de bron', async () => { const id = asset('BTC', 'crypto'); nep.history = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'eulerpool', points: [ [vandaag() - 2 * DAG, 60000, 'day'], [vandaag() - 2 * DAG + 3600_000, 60500, 'moment'], [vandaag() - DAG, 61000, 'day'], [vandaag(), 62000, 'day'], ] }); const uit = await serviceDayHistory(id, sinds()); assert.deepEqual(uit, { status: 'ok', instrument: 'crypto:BTC', currency: 'USD', source: 'eulerpool', days: [[vandaag() - 2 * DAG, 60000], [vandaag() - DAG, 61000]] }); assert.deepEqual(Object.keys(nep.historyCalls[0]).sort(), ['from', 'instrument', 'quote', 'to']); assert.equal(Object.values(nep.historyCalls[0]).includes(id), false, 'een asset-id ging de deur uit'); }); it('houdt per dag de bron die de dienst noemde, bij zijn naam (de alias)', async () => { nep.history = async (o) => ({ instrument: o.instrument, currency: 'EUR', source: 'bitvavo', sourceLabels: { bitvavo: 'Bitvavo (beurs)' }, points: [ [vandaag() - 2 * DAG, 60000, 'day', 'bitvavo'], [vandaag() - DAG, 61000, 'day', 'onbekend'], [vandaag() - DAG + 1, 61500, 'day'], ] }); const id = asset('BTC', 'crypto'); const uit = await serviceDayHistory(id, sinds()); assert.equal(uit.days[0][2], 'Bitvavo (beurs)', 'de alias uit sourceLabels'); assert.equal(uit.days[1][2], 'onbekend', 'zonder naam de sleutel'); assert.equal(uit.days[2].length, 2, 'een rij zonder bron krijgt er geen'); }); it('metaal per gram', async () => { nep.history = async (o) => ({ instrument: o.instrument, currency: 'USD', source: 'eulerpool', points: [[vandaag() - DAG, 2488.278144, 'day']] }); const uit = await serviceDayHistory(asset('XAU', 'metal'), sinds()); assert.ok(Math.abs(uit.days[0][1] - 80) < 1e-9); }); it('een instrument zonder historie in het register wordt niet gevraagd', async () => { assert.deepEqual(await serviceDayHistory(asset('ETH', 'crypto'), sinds()), { status: 'notCovered' }); assert.equal(nep.historyCalls.length, 0); }); it('zonder token uit, en een onbereikbare dienst is een antwoord', async () => { const id = asset('BTC', 'crypto'); nep.configured = false; assert.deepEqual(await serviceDayHistory(id, sinds()), { status: 'off' }); nep.configured = true; nep.history = async () => { throw new AppError('marketService.unreachable', { url: 'u', reason: 'x' }, 'x'); }; assert.equal((await serviceDayHistory(id, sinds())).retryLater, true); }); it('weigert een begin in de toekomst of verder terug dan de grens', async () => { const id = asset('BTC', 'crypto'); for (const slecht of [Date.now() + DAG, 'gisteren', Date.now() - (CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS + 2) * DAG]) { await assert.rejects(() => serviceDayHistory(id, slecht), /sinceMs/); } }); });