// Generated, do not edit. sha256:0218bafc8f02c1e9ace72164cc21b81de083223f11fc50e42ec7fedf10c23058 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('Beheer', { 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'); }); 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('proxy', { 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'); }); });