Files

410 lines
21 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// 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/);
}
});
});