Files

651 lines
35 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 15:24:06 +01:00
// Generated, do not edit. sha256:90b8d22d967cee3d2817f9d1b730418df66b8dba385cbdf2b3f5d3091a0a67ef
2026-10-29 11:57:29 +01:00
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
marketServiceStatus, createMarketClient, isRetryLater,
marketConfigured,
} from '../../server/market-service.mjs';
import { writeActiveName } from '../../server/portfolios.mjs';
import { readSecretsStore, writeSecretsStore } from '../../server/keys.mjs';
import { CONFIG } from '../../shared/config.js';
const URL_OK = 'http://10.0.0.11:8091';
const TOKEN = 'ats_0123456789abcdef';
const HEALTH = { ok: true, app: 'Atreides Services', version: '0.1.0' };
const REGISTER = {
instruments: [
{ key: 'crypto:BTC', kind: 'crypto', symbol: 'BTC', quotes: ['EUR'], resolutions: ['minute', 'hour', 'day'] },
{ key: 'metal:XAU', kind: 'metal', symbol: 'XAU', quotes: ['EUR', 'USD'], resolutions: ['day'] },
],
};
const json = (body, status = 200, headers = {}) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });
function fakeFetch(routes) {
const calls = [];
const fn = async (url, init = {}) => {
const u = new URL(url);
const rawUrl = u.href;
if (init.body) {
for (const [k, v] of Object.entries(JSON.parse(init.body))) u.searchParams.set(k, Array.isArray(v) ? v.join(',') : String(v));
}
calls.push({ url: u, rawUrl, headers: init.headers || {}, method: init.method || 'GET', body: init.body });
const route = routes[u.pathname];
if (!route) return json({ error: 'Not found', key: 'api.notFound' }, 404);
return route(u, init);
};
fn.calls = calls;
return fn;
}
const client = (routes, cfg = { url: URL_OK, token: TOKEN }, extra = {}) => {
const fetch = fakeFetch(routes);
return { fetch, c: createMarketClient({ fetch, config: async () => cfg, journal: null, ...extra }) };
};
function journaal({ kapot = false } = {}) {
const j = { rijen: [], tellingen: [] };
j.failure = async (entry, secrets) => { if (kapot) throw new Error('log kapot'); j.rijen.push({ entry, secrets }); };
j.count = async (c) => { if (kapot) throw new Error('log kapot'); j.tellingen.push(c); };
return j;
}
async function rejectsWith(promise, key) {
let fout = null;
await assert.rejects(promise, (e) => { fout = e; return e.key === key; });
return fout;
}
describe('createMarketClient: de verbindingstest', () => {
it('slaagt in twee stappen: health zonder token, het register mét', async () => {
const { fetch, c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json(REGISTER) });
const uit = await c.testConnection();
assert.deepEqual(uit, { url: URL_OK, app: 'Atreides Services', version: '0.1.0', instruments: 2 });
assert.deepEqual(fetch.calls.map(x => x.url.pathname), ['/v1/health', '/v1/instruments']);
assert.equal(fetch.calls[0].headers.Authorization, undefined, 'health hoort zonder token');
assert.equal(fetch.calls[1].headers.Authorization, `Bearer ${TOKEN}`);
for (const x of fetch.calls) assert.equal(x.rawUrl.includes(TOKEN) || String(x.body).includes(TOKEN), false, 'het token staat in de URL of de body');
assert.deepEqual([fetch.calls[0].method, fetch.calls[0].body], ['GET', undefined]);
assert.equal(fetch.calls[1].method, 'POST');
assert.equal(fetch.calls[1].headers['Content-Type'], 'application/json');
});
it('zet geen parameter in de URL van een POST, want een proxylog bewaart de URL', async () => {
const { fetch, c } = client({ '/v1/spot': () => json({ spot: [], missing: [], unreachable: [] }) });
await c.spots(['crypto:BTC', 'crypto:ETH'], 'EUR');
assert.equal(new URL(fetch.calls[0].rawUrl).search, '');
assert.deepEqual(JSON.parse(fetch.calls[0].body), { instruments: ['crypto:BTC', 'crypto:ETH'], quote: 'EUR' });
});
it('zonder token gaat het register de deur niet uit', async () => {
const { fetch, c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json(REGISTER) },
{ url: URL_OK, token: '' });
await rejectsWith(c.testConnection(), 'marketService.noToken');
assert.deepEqual(fetch.calls.map(x => x.url.pathname), ['/v1/health']);
});
it('herkent de beheer-app (of iets anders) op het opgegeven adres', async () => {
const html = () => new Response('<!doctype html><title>Beheer</title>', { status: 200, headers: { 'Content-Type': 'text/html' } });
await rejectsWith(client({ '/v1/health': html }).c.testConnection(), 'marketService.badAnswer');
const ander = () => json({ ok: true, app: 'Iets anders' });
await rejectsWith(client({ '/v1/health': ander }).c.testConnection(), 'marketService.badAnswer');
});
it('weigert een register dat geen lijst is', async () => {
const { c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json({ instruments: null }) });
await rejectsWith(c.testConnection(), 'marketService.badAnswer');
});
});
describe('createMarketClient: de onwelkome paden', () => {
const registerMet = (antwoord) => client({ '/v1/instruments': antwoord }).c;
it('dienst weg: "onbereikbaar", met de oorzaak, en later opnieuw', async () => {
const weg = () => { throw new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } }); };
const e = await rejectsWith(registerMet(weg).instruments(), 'marketService.unreachable');
assert.deepEqual(e.params, { url: URL_OK, reason: 'ECONNREFUSED' });
assert.equal(isRetryLater(e), true);
});
it('een dienst die niet antwoordt: timeout, en later opnieuw', async () => {
const hangt = (u, init) => new Promise((_, reject) => {
init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
});
const { c } = client({ '/v1/instruments': hangt }, undefined, { timeoutMs: 20 });
const e = await rejectsWith(c.instruments(), 'marketService.timeout');
assert.equal(isRetryLater(e), true);
});
it('401: het token, en NIET later opnieuw', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Unauthorized', key: 'api.unauthorized' }, 401)).instruments(),
'marketService.unauthorized');
assert.equal(isRetryLater(e), false);
});
it('429: te veel verzoeken, met de wachttijd uit Retry-After', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Too many', key: 'api.tooManyRequests' }, 429, { 'Retry-After': '30' })).instruments(),
'marketService.tooManyRequests');
assert.deepEqual(e.params, { s: 30 });
assert.equal(isRetryLater(e), true);
});
it('503 api.monthUnreachable: een maand die nu niet te lezen is, NOOIT "geen koersen"', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Month unreachable', key: 'api.monthUnreachable', month: '2026-09' }, 503)).instruments(),
'marketService.monthUnreachable');
assert.deepEqual(e.params, { month: '2026-09' });
assert.equal(isRetryLater(e), true);
});
it('503 zonder die sleutel: de dienst start nog, later opnieuw', async () => {
const e = await rejectsWith(registerMet(() => json({ error: 'Starting' }, 503)).instruments(),
'marketService.unavailable');
assert.equal(isRetryLater(e), true);
});
it('een andere weigering draagt de sleutel van de dienst mee en is geen "later opnieuw"', async () => {
const e = await rejectsWith(
registerMet(() => json({ error: 'Bad time', key: 'api.badTime' }, 400)).instruments(),
'marketService.rejected');
assert.deepEqual(e.params, { status: 400, key: 'api.badTime', reason: 'Bad time' });
assert.equal(isRetryLater(e), false);
});
it('een fout zonder JSON-body levert toch een leesbare weigering op', async () => {
const kaal = () => new Response('Bad Gateway', { status: 502 });
const e = await rejectsWith(registerMet(kaal).instruments(), 'marketService.rejected');
assert.deepEqual(e.params, { status: 502, key: '-', reason: 'HTTP 502' });
});
it('isRetryLater zegt nee tegen een gewone fout en tegen niets', () => {
assert.equal(isRetryLater(new Error('x')), false);
assert.equal(isRetryLater(null), false);
});
});
describe('createMarketClient: de reeks (fase 2)', () => {
const FROM = Date.parse('2026-10-09T00:00:00Z');
const TO = Date.parse('2026-10-09T12:00:00Z');
const minuut = (rows, extra = {}) => json({
instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', kind: 'spot', resolution: 'minute',
columns: ['t', 'price'], rows, next: null, ...extra,
});
it('neemt de bron per rij mee als derde element, en laat een rij zonder bron op twee', async () => {
const { c } = client({ '/v1/series': () => minuut(
[[FROM + 60_000, 61000, 'bitvavo'], [FROM + 120_000, 61010, 'mempool'], [FROM + 180_000, 61020, null]],
{ columns: ['t', 'price', 'source'], source: 'mempool' }) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM + 60_000, 61000, 'bitvavo'], [FROM + 120_000, 61010, 'mempool'], [FROM + 180_000, 61020]]);
assert.equal(uit.source, 'mempool');
});
it('neemt de naam per bronsleutel mee, over alle pagina\'s, en negeert een naam die geen tekst is', async () => {
const MID = FROM + 6 * 3600_000;
const { c } = client({ '/v1/series': (u) => (Number(u.searchParams.get('from')) === FROM
? minuut([[FROM + 60_000, 1, 'bitvavo']], { columns: ['t', 'price', 'source'], next: MID,
sourceLabel: 'Een Nederlandse aanbieder', sourceLabels: { bitvavo: 'Een Nederlandse aanbieder', kapot: 7 } })
: minuut([[MID + 60_000, 2, 'mempool']], { columns: ['t', 'price', 'source'], sourceLabels: { mempool: 'mempool.space' } })) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.equal(uit.sourceLabel, 'Een Nederlandse aanbieder');
assert.deepEqual(uit.sourceLabels, { bitvavo: 'Een Nederlandse aanbieder', mempool: 'mempool.space' });
});
it('vraagt instrument, munt, resolutie en periode, en geeft [t, prijs] terug', async () => {
const { fetch, c } = client({ '/v1/series': () => minuut([[FROM + 60_000, 61000], [FROM + 120_000, 61010]]) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit, { instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', sourceLabel: null, resolution: 'minute',
sourceLabels: {}, points: [[FROM + 60_000, 61000], [FROM + 120_000, 61010]] });
const q = fetch.calls[0].url.searchParams;
assert.deepEqual(Object.fromEntries(q), { instrument: 'crypto:BTC', quote: 'EUR', resolution: 'minute', from: String(FROM), to: String(TO) });
});
it('neemt de munt uit het ANTWOORD: EUR gevraagd en USD gekregen blijft USD', async () => {
const { c } = client({ '/v1/series': () => minuut([[FROM + 60_000, 66000]], { currency: 'USD' }) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.equal(uit.currency, 'USD');
});
it('volgt `next` en plakt de pagina\'s aan elkaar', async () => {
const MID = FROM + 6 * 3600_000;
const { fetch, c } = client({ '/v1/series': (u) => (Number(u.searchParams.get('from')) === FROM
? minuut([[FROM + 60_000, 1]], { next: MID })
: minuut([[MID + 60_000, 2]])) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM + 60_000, 1], [MID + 60_000, 2]]);
assert.equal(fetch.calls.length, 2);
});
it('stopt bij een `next` die niet vooruit gaat, in plaats van eindeloos te vragen', async () => {
const { fetch, c } = client({ '/v1/series': () => minuut([[FROM + 60_000, 1]], { next: FROM }) });
await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.equal(fetch.calls.length, 1);
});
it('leest een samengevatte resolutie op `close`', async () => {
const { c } = client({ '/v1/series': () => minuut([[FROM, 1, 3, 0.5, 2, 60]],
{ resolution: 'hour', columns: ['t', 'open', 'high', 'low', 'close', 'samples'] }) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', resolution: 'hour', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM, 2]]);
});
it('slaat rijen zonder bruikbare tijd of prijs over', async () => {
const { c } = client({ '/v1/series': () => minuut([[FROM, 1], [null, 2], [FROM + 1, 'x'], [FROM + 2, 0], [FROM + 3, 4]]) });
const uit = await c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM, 1], [FROM + 3, 4]]);
});
it('weigert een antwoord zonder munt of zonder prijskolom', async () => {
const zonderMunt = client({ '/v1/series': () => minuut([[FROM, 1]], { currency: undefined }) }).c;
await rejectsWith(zonderMunt.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
const zonderPrijs = client({ '/v1/series': () => minuut([[FROM, 1]], { columns: ['t', 'volume'] }) }).c;
await rejectsWith(zonderPrijs.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
});
it('weigert een munt die tussen twee pagina\'s verandert', async () => {
const MID = FROM + 3600_000;
const { c } = client({ '/v1/series': (u) => (Number(u.searchParams.get('from')) === FROM
? minuut([[FROM, 1]], { next: MID })
: minuut([[MID, 2]], { currency: 'USD' })) });
await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
});
2026-10-29 15:24:06 +01:00
it('de historie neemt de bron per rij mee als vierde element, met de naam per bronsleutel', async () => {
const { c } = client({ '/v1/series': () => json({
instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', kind: 'history',
columns: ['t', 'price', 'resolution', 'source'], sourceLabels: { bitvavo: 'Een Nederlandse aanbieder' },
rows: [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day', null]], next: null }) });
const uit = await c.history({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day']]);
assert.deepEqual(uit.sourceLabels, { bitvavo: 'Een Nederlandse aanbieder' });
});
2026-10-29 11:57:29 +01:00
it('503 api.monthUnreachable op een reeks: later opnieuw, met de maand', async () => {
const { c } = client({ '/v1/series': () => json({ error: 'x', key: 'api.monthUnreachable', month: '2026-09' }, 503) });
const e = await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.monthUnreachable');
assert.equal(isRetryLater(e), true);
});
});
describe('createMarketClient: spot per pakket (fase 3)', () => {
const spotAntwoord = (body) => json({ spot: [], missing: [], unreachable: [], ...body });
const punt = (instrument, price, currency = 'EUR') =>
({ instrument, price, currency, observedAt: 1791549296000, fetchedAt: 1791549301000, source: 'bitvavo', sourceLabel: 'Bitvavo', attribution: null });
it('vraagt het hele pakket in één aanroep, gesorteerd en zonder dubbelen, in de gevraagde munt', async () => {
const { fetch, c } = client({ '/v1/spot': () => spotAntwoord({ spot: [punt('crypto:BTC', 61000)] }) });
await c.spots(['crypto:ETH', 'crypto:BTC', 'crypto:ETH'], 'EUR');
assert.equal(fetch.calls.length, 1);
assert.deepEqual(Object.fromEntries(fetch.calls[0].url.searchParams), { instruments: 'crypto:BTC,crypto:ETH', quote: 'EUR' });
});
it('houdt een prijs, "geen koers" en "nu niet te lezen" uit elkaar', async () => {
const { c } = client({ '/v1/spot': () => spotAntwoord({
spot: [punt('crypto:BTC', 61000, 'USD')], missing: ['crypto:ETH'], unreachable: ['metal:XAG'] }) });
const r = await c.spots(['crypto:BTC', 'crypto:ETH', 'metal:XAG'], 'EUR');
assert.deepEqual(r.spot, [{ instrument: 'crypto:BTC', price: 61000, currency: 'USD', observedAt: 1791549296000, source: 'bitvavo', sourceLabel: 'Bitvavo' }]);
assert.deepEqual(r.missing, ['crypto:ETH']);
assert.deepEqual(r.unreachable, ['metal:XAG']);
});
it('slaat een punt zonder prijs of munt over in plaats van het als koers te geven', async () => {
const { c } = client({ '/v1/spot': () => spotAntwoord({ spot: [
punt('crypto:BTC', 0), { ...punt('crypto:ETH', 3000), currency: '' }, { ...punt('crypto:LTC', 'x') }, punt('crypto:DOT', 5)] }) });
const r = await c.spots(['crypto:BTC', 'crypto:ETH', 'crypto:LTC', 'crypto:DOT'], 'EUR');
assert.deepEqual(r.spot.map(s => s.instrument), ['crypto:DOT']);
});
it('splitst een pakket boven de grens van de dienst over meer aanroepen', async () => {
const max = CONFIG.MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT;
const keys = Array.from({ length: max + 5 }, (_, i) => `crypto:T${String(i).padStart(4, '0')}`);
const { fetch, c } = client({ '/v1/spot': (u) => spotAntwoord({
spot: u.searchParams.get('instruments').split(',').map(k => punt(k, 1)) }) });
const r = await c.spots(keys, 'EUR');
assert.equal(fetch.calls.length, 2);
assert.ok(fetch.calls.every(x => x.url.searchParams.get('instruments').split(',').length <= max));
assert.equal(r.spot.length, max + 5);
});
it('één antwoord dient alle assets een korte tijd; een ander pakket of daarna wordt opnieuw gevraagd', async () => {
let nu = 0;
const fetch = fakeFetch({ '/v1/spot': () => spotAntwoord({ spot: [punt('crypto:BTC', 1)] }) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), now: () => nu });
await c.spots(['crypto:BTC', 'crypto:ETH'], 'EUR');
await c.spots(['crypto:ETH', 'crypto:BTC'], 'EUR');
assert.equal(fetch.calls.length, 1, 'zelfde pakket in een andere volgorde is dezelfde vraag');
await c.spots(['crypto:BTC'], 'EUR');
assert.equal(fetch.calls.length, 2, 'een ander pakket');
nu += CONFIG.MARKET_SERVICE.SPOT_TTL_MS;
await c.spots(['crypto:BTC'], 'EUR');
assert.equal(fetch.calls.length, 3, 'na de bewaartijd');
});
it('weigert een antwoord zonder lijst met koersen', async () => {
const { c } = client({ '/v1/spot': () => json({ missing: [] }) });
await rejectsWith(c.spots(['crypto:BTC'], 'EUR'), 'marketService.badAnswer');
});
});
describe('createMarketClient: het register wordt even bewaard', () => {
it('vraagt het register één keer binnen de bewaartijd, en opnieuw daarna', async () => {
let nu = 1_000_000;
const fetch = fakeFetch({ '/v1/instruments': () => json(REGISTER) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), now: () => nu });
await c.register();
await c.register();
assert.equal(fetch.calls.length, 1);
nu += CONFIG.MARKET_SERVICE.REGISTER_TTL_MS;
await c.register();
assert.equal(fetch.calls.length, 2);
});
it('vraagt opnieuw na een ander adres, want een andere portefeuille kan een andere dienst hebben', async () => {
let cfg = { url: URL_OK, token: TOKEN };
const fetch = fakeFetch({ '/v1/instruments': () => json(REGISTER) });
const c = createMarketClient({ fetch, config: async () => cfg, now: () => 0 });
await c.register();
cfg = { url: 'http://10.0.0.12:3855', token: TOKEN };
await c.register();
assert.equal(fetch.calls.length, 2);
});
it('bewaart een mislukte vraag niet', async () => {
let weg = true;
const fetch = fakeFetch({ '/v1/instruments': () => (weg ? json({ error: 'x' }, 503) : json(REGISTER)) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), now: () => 0 });
await rejectsWith(c.register(), 'marketService.unavailable');
weg = false;
assert.equal((await c.register()).length, 2);
});
});
describe('de instellingen van de installatie', () => {
let DIR;
let vorige;
beforeEach(async () => {
vorige = process.env.ASSETAPP_DATA_DIR;
DIR = await mkdtemp(join(tmpdir(), 'assetapp-market-'));
process.env.ASSETAPP_DATA_DIR = DIR;
writeActiveName('Echt');
});
afterEach(async () => {
if (vorige === undefined) delete process.env.ASSETAPP_DATA_DIR;
else process.env.ASSETAPP_DATA_DIR = vorige;
await rm(DIR, { recursive: true, force: true });
});
const bestand = () => join(DIR, CONFIG.MARKET_SERVICE.SETTINGS_FILE_NAME);
const bewaard = async () => {
try { return JSON.parse(await readFile(bestand(), 'utf8')); } catch { return undefined; }
};
it('gebruikt het vaste adres, en alleen ASSETAPP_SERVICE_URL overschrijft het', async () => {
await writeFile(bestand(), JSON.stringify({ url: 'http://oud:8091', token: TOKEN }));
const vast = await marketServiceStatus();
assert.equal(vast.url, CONFIG.MARKET_SERVICE.URL, 'een adres uit het bestand telt nog mee');
assert.equal(vast.override, false);
process.env.ASSETAPP_SERVICE_URL = ' http://10.0.0.11:8091/ ';
try {
const anders = await marketServiceStatus();
assert.deepEqual([anders.url, anders.override], [URL_OK, true]);
process.env.ASSETAPP_SERVICE_URL = '10.0.0.11:8091';
await rejectsWith(marketServiceStatus(), 'marketService.urlScheme');
} finally {
delete process.env.ASSETAPP_SERVICE_URL;
}
});
it('geeft het token NIET terug, en zegt waar het vandaan komt', async () => {
await writeFile(bestand(), JSON.stringify({ token: TOKEN }));
const st = await marketServiceStatus();
assert.equal(JSON.stringify(st).includes(TOKEN), false, 'het token staat in de status');
assert.deepEqual([st.token_set, st.token_source], [true, 'manual']);
});
it('kan altijd een token aanvragen, en vanuit de app is er niets in te stellen of los te koppelen', async () => {
assert.equal((await marketServiceStatus()).can_register, true);
assert.equal(await marketConfigured(), true);
const exports = await import('../../server/market-service.mjs');
assert.equal(Object.keys(exports).some(k => /forget|disconnect|setMarketService/i.test(k)), false,
'er is weer een manier om adres of token te zetten, of om te ontkoppelen');
});
it('is in elke portefeuille verbonden, ook in een nieuwe lege', async () => {
await writeFile(bestand(), JSON.stringify({ token: TOKEN }));
writeActiveName('Nieuw');
assert.equal((await marketServiceStatus()).token_set, true);
assert.equal(await marketConfigured(), true);
});
it('neemt het token één keer over uit de portefeuille als eigen token, en haalt het daar weg', async () => {
await writeSecretsStore({ market_service: { url: URL_OK, token: TOKEN }, lnd_2: { macaroon: 'bb' } });
assert.equal((await marketServiceStatus()).token_source, 'manual');
assert.deepEqual(await bewaard(), { token: TOKEN }, 'het oude adres ging mee');
const alles = await readSecretsStore();
assert.equal('market_service' in alles, false, 'de oude kopie bleef in de portefeuille staan');
assert.equal(alles.lnd_2.macaroon, 'bb');
writeActiveName('Ander');
assert.equal(await marketConfigured(), true, 'een andere portefeuille ziet het overgenomen token niet');
});
it('laat de installatie winnen van een achtergebleven kopie in een portefeuille', async () => {
await writeFile(bestand(), JSON.stringify({ token: TOKEN }));
writeActiveName('Oud');
await writeSecretsStore({ market_service: { url: 'http://oud:8091', token: 'ats_oud' } });
assert.equal((await marketServiceStatus()).token_set, true);
assert.deepEqual(await bewaard(), { token: TOKEN });
assert.equal('market_service' in (await readSecretsStore()), false);
});
it('weigert een kapot instellingenbestand in plaats van "niet ingesteld" te raden', async () => {
await writeFile(bestand(), '{ kapot');
await assert.rejects(marketServiceStatus(), SyntaxError);
});
});
describe('createMarketClient: het integratielog', () => {
const ID = 'a1b2c3d4-0000-4000-8000-000000000001';
const FROM = Date.parse('2026-10-09T00:00:00Z');
const metLog = (routes, cfg, extra = {}) => {
const j = journaal(extra.journaal);
const { fetch, c } = client(routes, cfg, { journal: j, requestId: () => ID, ...extra.client });
return { fetch, c, j };
};
it('stuurt bij elke aanroep een eigen willekeurige X-Request-Id mee, ook naar health', async () => {
const { fetch, c } = client({ '/v1/health': () => json(HEALTH), '/v1/instruments': () => json(REGISTER) },
undefined, { journal: null, requestId: undefined });
await c.testConnection();
const ids = fetch.calls.map(x => x.headers['X-Request-Id']);
for (const id of ids) assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.notEqual(ids[0], ids[1]);
});
it('legt een weigering vast met de request-id, beide sleutels en de reden, en geeft het token mee om te schonen', async () => {
const { c, j } = metLog({ '/v1/series': () => json({ error: 'No series', key: 'api.noSeries', requestId: ID }, 404) });
const e = await rejectsWith(c.history({ instrument: 'metal:XAU', quote: 'EUR', from: FROM, to: FROM + 1 }), 'marketService.rejected');
assert.equal(e.requestId, ID, 'de fout draagt de id, zodat het antwoord aan de browser hem kan noemen');
assert.equal(j.rijen.length, 1);
const { entry, secrets } = j.rijen[0];
assert.deepEqual({ ...entry, latencyMs: typeof entry.latencyMs, occurredAt: typeof entry.occurredAt }, {
target: 'service', operation: '/v1/series', subject: 'metal:XAU', outcome: 'fail', httpStatus: 404,
errorKey: 'marketService.rejected api.noSeries', detail: 'No series', requestId: ID,
latencyMs: 'number', occurredAt: 'number',
});
assert.deepEqual(secrets, [TOKEN]);
assert.deepEqual(j.tellingen.map(t => [t.capability, t.ok]), [['daily_history', false]]);
});
it('beschrijft een antwoord dat geen JSON is: type en lengte, niet de inhoud', async () => {
const html = () => new Response('<html>proxy</html>', { status: 502, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
const { c, j } = metLog({ '/v1/spot': html });
await rejectsWith(c.spots(['crypto:BTC'], 'EUR'), 'marketService.rejected');
assert.equal(j.rijen[0].entry.detail, 'no JSON (text/html, 18 bytes)');
assert.equal(j.rijen[0].entry.errorKey, 'marketService.rejected');
assert.equal(j.rijen[0].entry.subject, '1', 'bij een pakket het aantal, niet de lijst');
const { c: c2, j: j2 } = metLog({ '/v1/spot': () => new Response('', { status: 200 }) });
await rejectsWith(c2.spots(['crypto:BTC'], 'EUR'), 'marketService.badAnswer');
assert.equal(j2.rijen[0].entry.detail, 'no JSON (text/plain, 0 bytes)');
assert.equal(j2.rijen[0].entry.httpStatus, 200);
});
it('dienst weg: geen status, de oorzaak als detail', async () => {
const weg = () => { throw new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } }); };
const { c, j } = metLog({ '/v1/instruments': weg });
await rejectsWith(c.instruments(), 'marketService.unreachable');
assert.deepEqual([j.rijen[0].entry.httpStatus, j.rijen[0].entry.detail], [null, 'ECONNREFUSED']);
assert.deepEqual(j.tellingen, [], 'het register is geen koersaanroep en telt niet');
});
it('telt een gelukte koersaanroep, en legt er geen rij voor vast', async () => {
const { c, j } = metLog({ '/v1/spot': () => json({ spot: [{ instrument: 'crypto:BTC', price: 1, currency: 'EUR' }] }) });
await c.spots(['crypto:BTC'], 'EUR');
assert.deepEqual(j.tellingen.map(t => [t.capability, t.ok]), [['spot', true]]);
assert.deepEqual(j.rijen, []);
});
it('een antwoord zonder bruikbare prijs krijgt een rij "empty"', async () => {
const { c, j } = metLog({
'/v1/series': () => json({ currency: 'EUR', columns: ['t', 'price', 'resolution'], rows: [], next: null }),
'/v1/spot': () => json({ spot: [], missing: ['crypto:BTC'] }),
});
await c.history({ instrument: 'metal:XAU', quote: 'EUR', from: FROM, to: FROM + 1 });
await c.spots(['crypto:BTC', 'crypto:ETH'], 'EUR');
assert.deepEqual(j.rijen.map(r => [r.entry.operation, r.entry.subject, r.entry.outcome, r.entry.requestId]),
[['/v1/series', 'metal:XAU', 'empty', ID], ['/v1/spot', '2', 'empty', ID]]);
});
it('zonder token: "refused", zonder request-id, en er gaat niets de deur uit', async () => {
const { fetch, c, j } = metLog({ '/v1/spot': () => json({ spot: [] }) }, { url: URL_OK, token: '' });
await rejectsWith(c.spots(['crypto:BTC'], 'EUR'), 'marketService.noToken');
assert.equal(fetch.calls.length, 0);
assert.deepEqual([j.rijen[0].entry.outcome, j.rijen[0].entry.requestId], ['refused', null]);
});
it('noemt een andere id die de dienst terugstuurt', async () => {
const { c, j } = metLog({ '/v1/series': () => json({ error: 'x', key: 'api.badTime' }, 400, { 'X-Request-Id': 'dienst-eigen-id' }) });
await rejectsWith(c.history({ instrument: 'metal:XAU', quote: 'EUR', from: FROM, to: FROM + 1 }), 'marketService.rejected');
assert.equal(j.rijen[0].entry.detail, 'x; service request id dienst-eigen-id');
});
it('een log dat zelf faalt breekt de aanroep niet', async () => {
const { c } = metLog({ '/v1/spot': () => json({ spot: [{ instrument: 'crypto:BTC', price: 1, currency: 'EUR' }] }) },
undefined, { journaal: { kapot: true } });
assert.equal((await c.spots(['crypto:BTC'], 'EUR')).spot.length, 1);
const { c: c2 } = metLog({ '/v1/spot': () => json({ error: 'x', key: 'api.badTime' }, 400) }, undefined, { journaal: { kapot: true } });
await rejectsWith(c2.spots(['crypto:BTC'], 'EUR'), 'marketService.rejected');
});
});
describe('createMarketClient: de verkoop', () => {
const NOSTR = 'Nostr eyJ0ZXN0Ijp0cnVlfQ==';
const ORDER = { id: 'a'.repeat(32), status: 'open', amountSat: 21000, paymentRequest: 'lnbc1', expiresAt: 1, paidAt: null, waived: false };
const KEY = 'b'.repeat(64);
it('plaatst een order met de handtekening zoals hij kwam, de body byte voor byte, en zonder token', async () => {
const body = '{"email":"a@b.nl"}';
const { fetch, c } = client({ '/v1/orders': () => json({ order: ORDER, readKey: KEY }, 201) });
assert.deepEqual(await c.placeOrder(NOSTR, body), { order: ORDER, readKey: KEY });
const x = fetch.calls[0];
assert.equal(x.method, 'POST');
assert.equal(x.headers.Authorization, NOSTR);
assert.equal(x.body, body, 'de body is onderweg veranderd, dan klopt de payload van de handtekening niet');
assert.equal(JSON.stringify(x).includes(TOKEN), false, 'het token van de portefeuille ging mee');
});
it('vraagt een order met het leesgeheim en de licenties met een handtekening, allebei als GET', async () => {
const { fetch, c } = client({
[`/v1/orders/${ORDER.id}`]: () => json({ order: { ...ORDER, status: 'paid' }, license: { x: 1 } }),
'/v1/license': () => json({ licenses: [] }),
'/v1/sale': () => json({ open: true, priceSat: 21000, major: 1 }),
});
assert.deepEqual(await c.orderStatus(ORDER.id, KEY), { order: { ...ORDER, status: 'paid' }, license: { x: 1 } });
assert.deepEqual(await c.buyerLicenses(NOSTR), []);
assert.deepEqual(await c.sale(), { open: true, priceSat: 21000, major: 1 });
assert.deepEqual(fetch.calls.map(x => [x.method, x.headers.Authorization]), [['GET', `Order ${KEY}`], ['GET', NOSTR], ['GET', undefined]]);
});
it('werkt zonder token: kopen kan ook een installatie die er nog geen heeft', async () => {
const { c } = client({ '/v1/sale': () => json({ open: false, priceSat: null, major: 1 }) }, { url: URL_OK, token: '' });
assert.equal((await c.sale()).open, false);
});
it('geeft elke weigering van de verkoop een eigen sleutel', async () => {
for (const [status, key, verwacht] of [
[503, 'api.salesClosed', 'marketService.salesClosed'], [503, 'api.checkoutUnavailable', 'marketService.checkoutUnavailable'],
[429, 'api.tooManyOrders', 'marketService.tooManyOrders'], [401, 'api.badAuth', 'marketService.signatureRefused'],
]) {
const { c } = client({ '/v1/orders': () => json({ error: 'nee', key }, status) });
await rejectsWith(c.placeOrder(NOSTR, '{}'), verwacht);
}
const { c } = client({});
await rejectsWith(c.orderStatus('c'.repeat(32), KEY), 'marketService.rejected');
const { c: c2 } = client({ [`/v1/orders/${ORDER.id}`]: () => json({ error: 'Unknown order.', key: 'api.unknownOrder' }, 404) });
await rejectsWith(c2.orderStatus(ORDER.id, KEY), 'marketService.unknownOrder');
});
it('geeft het integratielog de handtekening en het leesgeheim als geheim mee', async () => {
const j = journaal();
const fetch = fakeFetch({ [`/v1/orders/${ORDER.id}`]: () => json({ error: 'x', key: 'api.unknownOrder' }, 404) });
const c = createMarketClient({ fetch, config: async () => ({ url: URL_OK, token: TOKEN }), journal: j });
await rejectsWith(c.orderStatus(ORDER.id, KEY), 'marketService.unknownOrder');
assert.deepEqual(j.rijen[0].secrets, [TOKEN, `Order ${KEY}`]);
});
});
describe('createMarketClient: support', () => {
const NOSTR = 'Nostr eyJ0ZXN0Ijp0cnVlfQ==';
const MSG = { from: 'buyer', ai: false, text: 'hoi', at: 1, attachments: [] };
it('leest het gesprek, stuurt een bericht byte voor byte en haalt een screenshot op, zonder token', async () => {
const body = '{"text":"hoi","attachment":null}';
const id = 'c'.repeat(32);
const { fetch, c } = client({
'/v1/support/messages': (u, init) => (init.method === 'POST' ? json({ message: MSG }, 201) : json({ messages: [MSG] })),
[`/v1/support/attachments/${id}`]: () => json({ mime: 'image/png', data: 'iVBO' }),
});
assert.deepEqual(await c.supportMessages(NOSTR), [MSG]);
assert.deepEqual(await c.supportSend(NOSTR, body), MSG);
assert.deepEqual(await c.supportAttachment(NOSTR, id), { mime: 'image/png', data: 'iVBO' });
assert.deepEqual(fetch.calls.map(x => [x.method, x.headers.Authorization]), [['GET', NOSTR], ['POST', NOSTR], ['GET', NOSTR]]);
assert.equal(fetch.calls[1].body, body, 'de body is onderweg veranderd, dan klopt de payload van de handtekening niet');
assert.equal(JSON.stringify(fetch.calls).includes(TOKEN), false, 'het token van de portefeuille ging mee');
});
it('geeft elke weigering van een bericht een eigen sleutel, met de grens erin', async () => {
for (const [status, key, verwacht] of [
[400, 'support.empty', 'marketService.supportEmpty'], [400, 'support.tooLong', 'marketService.supportTooLong'],
[400, 'support.attachmentType', 'marketService.supportAttachmentType'], [400, 'support.attachmentEmpty', 'marketService.supportAttachmentType'],
[400, 'support.attachmentTooLarge', 'marketService.supportAttachmentTooLarge'], [429, 'support.tooMany', 'marketService.supportTooMany'],
]) {
const { c } = client({ '/v1/support/messages': () => json({ error: 'nee', key }, status) });
const e = await rejectsWith(c.supportSend(NOSTR, '{}'), verwacht);
if (verwacht === 'marketService.supportTooLong') assert.equal(e.params.max, 4000);
}
const { c } = client({ [`/v1/support/attachments/${'d'.repeat(32)}`]: () => json({ error: 'x', key: 'api.unknownAttachment' }, 404) });
await rejectsWith(c.supportAttachment(NOSTR, 'd'.repeat(32)), 'marketService.unknownAttachment');
});
it('weigert een antwoord dat geen gesprek of screenshot is', async () => {
const { c } = client({ '/v1/support/messages': () => json({ messages: 'nee' }) });
await rejectsWith(c.supportMessages(NOSTR), 'marketService.badAnswer');
});
});