Files
AtreidesAssetsFree/tests/price/custom-adapter.test.mjs
T

570 lines
28 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:5217955238b304b9e0b4f32509659a4a0f009e7d18d50b262129f84c861f0e94
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import {
resolveScalar, resolveColumn, mapSpot, mapHistoryPoint, mapDailyHistory,
hmacHeaders, buildRequest, priceCall, failureEntry,
} from '../../server/price/custom.js';
import { CONFIG } from '../../shared/config.js';
import { toUtcIso } from '../../shared/time.js';
const provider = (extra = {}) => ({
key: 'test', baseUrl: 'https://api.example.test',
authenticationKind: 'none', authenticationRequired: 0,
authenticationHeaderName: null, authenticationParameterName: null,
hmac: null, endpoints: [], symbols: [], outputs: [], ...extra,
});
const endpoint = (capability, pathTemplate, queryTemplate = null) =>
({ capability, method: 'GET', pathTemplate, queryTemplate });
const output = (capability, internalField, jsonPath, transform = 'none') =>
({ capability, internalField, jsonPath, transform });
describe('resolveScalar: over geparst JSON, nooit over tekst', () => {
const doc = { a: { b: { c: 42 } }, lijst: [{ prijs: 1 }, { prijs: 2 }], BTC: { eur: 7 } };
it('loopt een pad van objectsleutels af', () => {
assert.equal(resolveScalar(doc, 'a.b.c'), 42);
});
it('leest een numeriek segment als index en niet als sleutel', () => {
assert.equal(resolveScalar(doc, 'lijst.1.prijs'), 2);
});
it('vult {external_id} in als sleutelnaam', () => {
assert.equal(resolveScalar(doc, '{external_id}.eur', 'BTC'), 7);
});
it('gooit als het pad {external_id} gebruikt maar er geen is', () => {
assert.throws(() => resolveScalar(doc, '{external_id}.eur', null), (e) => e.key === 'integrations.price.externalIdMissing');
});
it('geeft undefined bij een pad dat halverwege doodloopt, en gooit niet', () => {
assert.equal(resolveScalar(doc, 'a.bestaat.niet.dieper'), undefined);
assert.equal(resolveScalar(doc, 'weg.helemaal'), undefined);
});
});
describe('resolveColumn: een kolom uit een lijst', () => {
it('leest een kolom uit een array van objecten', () => {
const doc = { prices: [{ t: 1, e: 10 }, { t: 2, e: 20 }] };
assert.deepEqual(resolveColumn(doc, 'prices[].t'), [1, 2]);
});
it('leest een kolom uit een array van arrays met een numerieke index', () => {
const doc = { prices: [[1000, 10], [2000, 20]] };
assert.deepEqual(resolveColumn(doc, 'prices[].1'), [10, 20]);
});
it('behandelt de wortel als array bij een pad dat met [] begint', () => {
assert.deepEqual(resolveColumn([{ x: 1 }, { x: 2 }], '[].x'), [1, 2]);
});
it('geeft de elementen zelf terug als er niets achter [] staat', () => {
assert.deepEqual(resolveColumn({ v: [1, 2, 3] }, 'v[]'), [1, 2, 3]);
});
it('gooit bij een lijst-pad zonder [], en bij een pad dat geen array aanwijst', () => {
assert.throws(() => resolveColumn({ a: [] }, 'a.b'), (e) => e.key === 'integrations.price.listPathWithoutBrackets');
assert.throws(() => resolveColumn({ a: 5 }, 'a[].x'), (e) => e.key === 'integrations.price.expectedArray');
});
});
describe('resolveColumn: een object met de rijen als sleutel', () => {
const doc = { rates: { '2026-09-01': { EUR: 0.86 }, '2026-09-02': { EUR: 0.87 } } };
it('geeft met @key de sleutels en met een veld de waarden, in dezelfde volgorde', () => {
assert.deepEqual(resolveColumn(doc, 'rates{}.@key'), ['2026-09-01', '2026-09-02']);
assert.deepEqual(resolveColumn(doc, 'rates{}.EUR'), [0.86, 0.87]);
});
it('geeft undefined voor een rij zonder het gevraagde veld, en gooit niet', () => {
assert.deepEqual(resolveColumn({ r: { a: { EUR: 1 }, b: { USD: 2 } } }, 'r{}.EUR'), [1, undefined]);
});
it('gooit als het pad geen object aanwijst, ook niet bij een array', () => {
assert.throws(() => resolveColumn({ r: [1, 2] }, 'r{}.EUR'), (e) => e.key === 'integrations.price.expectedObject');
assert.throws(() => resolveColumn({ r: 5 }, 'r{}.@key'), (e) => e.key === 'integrations.price.expectedObject');
});
});
describe('mapSpot', () => {
it('mapt het ene prijsveld op het interne contract', () => {
const p = provider({ outputs: [output('spot', 'price', 'data.eur')] });
assert.deepEqual(mapSpot({ data: { eur: 12.5 } }, p), { price: 12.5 });
});
it('past to_number toe op een prijs die als string binnenkomt', () => {
const p = provider({ outputs: [output('spot', 'price', 'data.eur', 'to_number')] });
assert.equal(mapSpot({ data: { eur: '12.5' } }, p).price, 12.5);
});
it('rekent troy ounce naar gram met de factor uit CONFIG', () => {
const p = provider({ outputs: [output('spot', 'price', 'price', 'troy_ounce_to_gram')] });
const uit = mapSpot({ price: CONFIG.METALS.GRAMS_PER_TROY_OUNCE }, p);
assert.equal(uit.price, 1, 'een ounce ter waarde van de gramfactor is precies 1 per gram');
});
it('weigert een respons zonder bruikbare prijs', () => {
const p = provider({ outputs: [output('spot', 'price', 'data.eur')] });
assert.throws(() => mapSpot({ data: {} }, p), (e) => e.key === 'integrations.price.noValidPrice');
assert.throws(() => mapSpot({ data: { eur: 'kapot' } }, p), (e) => e.key === 'integrations.price.noValidPrice');
});
it('weigert een provider zonder mapping voor deze capability', () => {
assert.throws(() => mapSpot({}, provider()), (e) => e.key === 'integrations.price.noOutputMapping');
});
it('gooit bij een transform die de adapter niet kent', () => {
const p = provider({ outputs: [output('spot', 'price', 'e', 'verzonnen')] });
assert.throws(() => mapSpot({ e: 1 }, p), (e) => e.key === 'integrations.price.unknownTransform');
});
});
describe('een endpoint dat in een andere munt antwoordt', () => {
const inDollars = (outputs, capability = 'spot') => provider({
endpoints: [{ ...endpoint(capability, '/p'), quoteCurrency: 'USD' }], outputs,
});
it('geeft price ongewijzigd door, in de munt van het endpoint', () => {
const p = inDollars([output('spot', 'price', 'price')]);
assert.deepEqual(mapSpot({ price: 2000 }, p), { price: 2000 });
});
it('weigert een antwoord zonder prijs, met de provider in de parameters', () => {
const p = inDollars([output('spot', 'price', 'price')]);
assert.throws(() => mapSpot({}, p), (e) => e.key === 'integrations.price.noValidPrice' && e.params.provider === 'test');
});
it('houdt in een reeks de rijen met een prijs en laat de rest vallen', () => {
const p = inDollars([output('daily_history', 'time', 'r[].0'), output('daily_history', 'price', 'r[].1')], 'daily_history');
assert.deepEqual(mapDailyHistory({ r: [[100, 5], [200, null]] }, p), [{ time: 100, price: 5 }]);
});
it('leest alleen price: een mapping op een ander veld levert geen koers op', () => {
const p = provider({ endpoints: [endpoint('spot', '/p')], outputs: [output('spot', 'EUR', 'price')] });
assert.throws(() => mapSpot({ price: 2000 }, p), (e) => e.key === 'integrations.price.noValidPrice');
});
});
describe('mapHistoryPoint', () => {
const basis = provider({ outputs: [output('history_point', 'price', 'eur')] });
it('leidt het tijdstip af uit de gevraagde datum als de respons er geen geeft', () => {
const uit = mapHistoryPoint({ eur: 3 }, basis, null, '2026-03-01');
assert.equal(uit.time, Math.floor(Date.parse('2026-03-01T00:00:00Z') / 1000));
assert.equal(uit.price, 3);
});
it('gebruikt het tijdstip uit de respons als dat er wél is', () => {
const p = provider({ outputs: [output('history_point', 'price', 'eur'), output('history_point', 'time', 't')] });
assert.equal(mapHistoryPoint({ eur: 3, t: 99 }, p, null, '2026-03-01').time, 99);
});
it('weigert een onleesbare datum als er ook geen tijdstip in de respons staat', () => {
assert.throws(() => mapHistoryPoint({ eur: 3 }, basis, null, 'geen-datum'), (e) => e.key === 'integrations.price.invalidDate');
});
});
describe('mapDailyHistory', () => {
const kolommen = (extra = []) => provider({
outputs: [output('daily_history', 'time', 'p[].0'), output('daily_history', 'price', 'p[].1'), ...extra],
});
it('koppelt de kolommen per index en sorteert oplopend op tijd', () => {
const uit = mapDailyHistory({ p: [[300, 3], [100, 1], [200, 2]] }, kolommen());
assert.deepEqual(uit.map(r => r.time), [100, 200, 300]);
assert.deepEqual(uit.map(r => r.price), [1, 2, 3]);
});
it('laat rijen vallen waarvan tijd of prijs onbruikbaar is', () => {
const uit = mapDailyHistory({ p: [[100, 1], [null, 2], [300, 'kapot']] }, kolommen());
assert.deepEqual(uit, [{ time: 100, price: 1 }]);
});
it('weigert kolommen van ongelijke lengte', () => {
const p = provider({
outputs: [output('daily_history', 'time', 't[]'), output('daily_history', 'price', 'e[]')],
});
assert.throws(() => mapDailyHistory({ t: [1, 2, 3], e: [10, 20] }, p), (e) => e.key === 'integrations.price.columnsUneven');
});
it('houdt bij last_per_day alleen het laatste punt van elke kalenderdag', () => {
const dag = (iso) => Math.floor(Date.parse(iso) / 1000);
const p = provider({
outputs: [output('daily_history', 'time', 'p[].0'),
output('daily_history', 'price', 'p[].1', 'last_per_day')],
});
const uit = mapDailyHistory({ p: [
[dag('2026-03-01T09:00:00Z'), 1], [dag('2026-03-01T21:00:00Z'), 2], [dag('2026-03-02T09:00:00Z'), 3],
] }, p);
assert.deepEqual(uit.map(r => r.price), [2, 3], 'per dag blijft de laatste over');
});
it('leest een reeks met datums als sleutel, en laat een sleutel die geen datum is vallen', () => {
const p = provider({
outputs: [output('daily_history', 'time', 'rates{}.@key', 'iso_date_to_s'),
output('daily_history', 'price', 'rates{}.EUR')],
});
const uit = mapDailyHistory({ rates: {
'2026-09-02': { EUR: 0.87 }, '2026-09-01': { EUR: 0.86 }, 'laatste': { EUR: 0.9 }, '2026-09-03': {},
} }, p);
assert.deepEqual(uit, [
{ time: Math.floor(Date.parse('2026-09-01T00:00:00Z') / 1000), price: 0.86 },
{ time: Math.floor(Date.parse('2026-09-02T00:00:00Z') / 1000), price: 0.87 },
], 'oplopend gesorteerd; geen datum of geen koers is geen rij');
});
it('dateert een slot op middernacht Amsterdam op zijn eigen dag en laat een maandcijfer vallen', () => {
const p = provider({
outputs: [output('daily_history', 'time', 'p[].0', 'ms_to_s_local_close'), output('daily_history', 'price', 'p[].1')],
endpoints: [{ ...endpoint('daily_history', '/p'), quoteCurrency: 'USD' }],
});
const dag = iso => Date.parse(`${iso}T00:00:00Z`) / 1000;
const uit = mapDailyHistory({ p: [
[1740697200000, 91.79],
[1740787200000, 100.33],
[1743631200000, 1.0208],
] }, p);
assert.deepEqual(uit.map(r => [r.time, r.price]), [[dag('2025-02-28'), 91.79], [dag('2025-04-03'), 1.0208]]);
});
it('geeft rond een maandgrens en een tijdwissel elke dag precies één slot', () => {
const p = provider({
outputs: [output('daily_history', 'time', 'p[].0', 'ms_to_s_local_close'), output('daily_history', 'price', 'p[].1')],
});
const slotVan = dag => Date.parse(toUtcIso(`${dag}T00:00:00`));
const dagen = (van, n) => Array.from({ length: n }, (_, i) => new Date(Date.parse(`${van}T00:00:00Z`) + i * 86400000).toISOString().slice(0, 10));
for (const week of [dagen('2026-08-29', 7), dagen('2026-10-22', 7), dagen('2026-03-26', 6)]) {
const reeks = week.map((d, i) => [slotVan(d), 100 + i]);
reeks.push([Date.parse('2026-09-01T00:00:00Z'), 999]);
const uit = mapDailyHistory({ p: reeks }, p);
assert.deepEqual(uit.map(r => new Date(r.time * 1000).toISOString().slice(0, 10)), week, `de dagen van ${week[0]}`);
assert.ok(!uit.some(r => r.price === 999), 'het maandcijfer valt weg');
}
});
it('laat een ander tijdstip ongemoeid, zoals de avondpunten van crypto', () => {
const p = provider({
outputs: [output('daily_history', 'time', 'p[].0', 'ms_to_s_local_close'), output('daily_history', 'price', 'p[].1')],
});
const uit = mapDailyHistory({ p: [[1758859200000, 1], [1759353813000, 2]] }, p);
assert.deepEqual(uit.map(r => r.time), [1758859200, 1759353813]);
});
});
describe('buildRequest: de poort die weigert zonder sleutel', () => {
const metAuth = (kind, extra = {}) => provider({
authenticationKind: kind, authenticationRequired: 1,
endpoints: [endpoint('spot', '/v1/prijs')], ...extra,
});
it('weigert als de sleutel verplicht is maar ontbreekt', async () => {
await assert.rejects(() => buildRequest(metAuth('header_key', { authenticationHeaderName: 'X-Key' }),
'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.keyMissing');
});
it('weigert een HALVE sleutel, en dat is het geval dat telt', async () => {
await assert.rejects(() => buildRequest(metAuth('basic'), 'spot', 'BTC', null, { api_key: 'k' }),
(e) => e.key === 'integrations.price.keyMissing');
});
it('laat een provider zonder verplichte sleutel gewoon door', async () => {
const p = provider({ endpoints: [endpoint('spot', '/v1/prijs')] });
const r = await buildRequest(p, 'spot', 'BTC', null, null);
assert.equal(r.url, 'https://api.example.test/v1/prijs');
assert.deepEqual(r.headers, {});
});
it('gooit bij een capability die de provider niet kent, en bij een onbekende capability', async () => {
await assert.rejects(() => buildRequest(provider(), 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.capabilityNotConfigured');
await assert.rejects(() => buildRequest(provider(), 'verzonnen', 'BTC', null, null), (e) => e.key === 'integrations.price.unknownCapability');
});
it('ondersteunt alleen GET', async () => {
const p = provider({ endpoints: [{ capability: 'spot', method: 'POST', pathTemplate: '/x', queryTemplate: null }] });
await assert.rejects(() => buildRequest(p, 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.onlyGet');
});
});
describe('buildRequest: de placeholders in het pad', () => {
it('vertaalt het symbool via de symbol-map en valt anders terug op het symbool zelf', async () => {
const p = provider({
endpoints: [endpoint('spot', '/v1/{external_id}')],
symbols: [{ symbol: 'WXT', externalId: 'wirex' }],
});
assert.equal((await buildRequest(p, 'spot', 'WXT', null, null)).url, 'https://api.example.test/v1/wirex');
assert.equal((await buildRequest(p, 'spot', 'XAU', null, null)).url, 'https://api.example.test/v1/XAU');
});
it('codeert de placeholder, zodat een raar extern id het pad niet kan openbreken', async () => {
const p = provider({
endpoints: [endpoint('spot', '/v1/{external_id}')],
symbols: [{ symbol: 'X', externalId: 'a/b?c' }],
});
assert.equal((await buildRequest(p, 'spot', 'X', null, null)).url, 'https://api.example.test/v1/a%2Fb%3Fc');
});
it('zet {date} om naar YYYYMMDD en weigert een onleesbare datum', async () => {
const p = provider({ endpoints: [endpoint('history_point', '/v1/{date}')] });
assert.equal((await buildRequest(p, 'history_point', 'X', '2026-03-01', null)).url,
'https://api.example.test/v1/20260301');
await assert.rejects(() => buildRequest(p, 'history_point', 'X', 'rommel', null), (e) => e.key === 'integrations.price.templateDateInvalid');
});
});
describe('buildRequest: de vier auth-soorten', () => {
const p = (kind, extra = {}) => provider({
authenticationKind: kind, endpoints: [endpoint('spot', '/v1/p', 'a=1')], ...extra,
});
it('zet een header bij header_key', async () => {
const r = await buildRequest(p('header_key', { authenticationHeaderName: 'X-Key' }), 'spot', 'X', null, { api_key: 'geheim' });
assert.equal(r.headers['X-Key'], 'geheim');
});
it('zet een Bearer-header bij bearer', async () => {
const r = await buildRequest(p('bearer'), 'spot', 'X', null, { api_key: 'geheim' });
assert.equal(r.headers.Authorization, 'Bearer geheim');
});
it('zet een base64-paar bij basic', async () => {
const r = await buildRequest(p('basic'), 'spot', 'X', null, { api_key: 'u', api_secret: 'w' });
assert.equal(r.headers.Authorization, 'Basic ' + Buffer.from('u:w').toString('base64'));
});
it('plakt de sleutel achter de query bij query_key, gecodeerd en zonder de bestaande query te slopen', async () => {
const r = await buildRequest(p('query_key', { authenticationParameterName: 'api key' }),
'spot', 'X', null, { api_key: 'a&b' });
assert.equal(r.url, 'https://api.example.test/v1/p?a=1&api%20key=a%26b');
});
it('gooit als de naam van de header of de queryparameter ontbreekt', async () => {
await assert.rejects(() => buildRequest(p('header_key'), 'spot', 'X', null, { api_key: 'k' }),
(e) => e.key === 'integrations.price.headerNameMissing');
await assert.rejects(() => buildRequest(p('query_key'), 'spot', 'X', null, { api_key: 'k' }),
(e) => e.key === 'integrations.price.parameterNameMissing');
});
it('gooit bij een authenticationKind die niet bestaat', async () => {
await assert.rejects(() => buildRequest(p('verzonnen'), 'spot', 'X', null, null), (e) => e.key === 'integrations.price.unknownAuthKind');
});
});
describe('hmacHeaders: het bericht dat ondertekend wordt', () => {
const hmac = {
messageParts: 'timestamp,method,full_path,body', hashAlgorithm: 'SHA-256', encoding: 'hex',
keyHeader: 'X-Key', signatureHeader: 'X-Sig', timestampHeader: 'X-Ts', windowHeader: null, windowMs: null,
};
const creds = { api_key: 'sleutel', api_secret: 'geheim' };
it('ondertekent precies de samengevoegde onderdelen, in de opgegeven volgorde', async () => {
const h = await hmacHeaders(hmac, 'GET', '/v1/p?a=1', '', creds);
const verwacht = createHmac('sha256', creds.api_secret)
.update(`${h['X-Ts']}GET/v1/p?a=1`).digest('hex');
assert.equal(h['X-Sig'], verwacht);
assert.equal(h['X-Key'], 'sleutel');
assert.match(h['X-Ts'], /^\d{13}$/, 'een tijdstempel in milliseconden');
});
it('volgt de volgorde uit messageParts en niet een vaste volgorde', async () => {
const omgekeerd = { ...hmac, messageParts: 'method,timestamp' };
const h = await hmacHeaders(omgekeerd, 'GET', '/x', '', creds);
assert.equal(h['X-Sig'], createHmac('sha256', creds.api_secret).update(`GET${h['X-Ts']}`).digest('hex'));
});
it('kan base64 in plaats van hex', async () => {
const h = await hmacHeaders({ ...hmac, encoding: 'base64' }, 'GET', '/x', '', creds);
assert.equal(h['X-Sig'], createHmac('sha256', creds.api_secret).update(`${h['X-Ts']}GET/x`).digest('base64'));
});
it('zet de venster-header alleen als hij geconfigureerd is', async () => {
assert.equal('X-Win' in await hmacHeaders(hmac, 'GET', '/x', '', creds), false);
const h = await hmacHeaders({ ...hmac, windowHeader: 'X-Win', windowMs: 5000 }, 'GET', '/x', '', creds);
assert.equal(h['X-Win'], '5000');
});
it('gooit bij een onbekend onderdeel in messageParts', async () => {
await assert.rejects(() => hmacHeaders({ ...hmac, messageParts: 'timestamp,verzonnen' }, 'GET', '/x', '', creds),
(e) => e.key === 'integrations.price.unknownHmacToken');
});
it('ondertekent het VOLLEDIGE pad inclusief query', async () => {
const p = provider({
authenticationKind: 'hmac', hmac, endpoints: [endpoint('spot', '/v1/p', 'a=1')],
});
const r = await buildRequest(p, 'spot', 'X', null, creds);
assert.equal(r.headers['X-Sig'],
createHmac('sha256', creds.api_secret).update(`${r.headers['X-Ts']}GET/v1/p?a=1`).digest('hex'));
});
it('gooit als de hmac-config bij de provider ontbreekt', async () => {
const p = provider({ authenticationKind: 'hmac', hmac: null, endpoints: [endpoint('spot', '/v1/p')] });
await assert.rejects(() => buildRequest(p, 'spot', 'X', null, creds), (e) => e.key === 'integrations.price.hmacConfigMissing');
});
});
describe('priceCall: de enige functie die het netwerk op gaat', () => {
const p = provider({
endpoints: [endpoint('spot', '/v1/prijs')],
outputs: [output('spot', 'price', 'data.eur')],
});
function metFetch(antwoord, dan) {
const origineel = globalThis.fetch;
const gezien = {};
globalThis.fetch = async (url, opties) => { gezien.url = url; gezien.opties = opties; return antwoord; };
return Promise.resolve(dan(gezien)).finally(() => { globalThis.fetch = origineel; });
}
const respons = (status, body) => new Response(JSON.stringify(body),
{ status, headers: { 'Content-Type': 'application/json' } });
it('mapt een geslaagde respons op het interne contract', async () => {
await metFetch(respons(200, { data: { eur: 42 } }), async (gezien) => {
assert.deepEqual(await priceCall(p, 'spot', 'BTC', null, null), { price: 42 });
assert.equal(gezien.url, 'https://api.example.test/v1/prijs');
});
});
it('geeft de ruwe respons erbij als daarom gevraagd wordt', async () => {
await metFetch(respons(200, { data: { eur: 42 } }), async () => {
const uit = await priceCall(p, 'spot', 'BTC', null, null, { withRaw: true });
assert.deepEqual(uit.result, { price: 42 });
assert.deepEqual(uit.raw, { data: { eur: 42 } }, 'raw is de geparste respons, niet de tekst');
});
});
it('meldt een foutstatus met de code erbij', async () => {
await metFetch(respons(429, { fout: 'te veel' }), async () => {
await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.httpFailed' && e.params.status === 429);
});
});
it('laat een respons die geen JSON is niet stil doorgaan', async () => {
const kapot = new Response('<html>storing</html>', { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
await metFetch(kapot, async () => {
await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) =>
e.key === 'integrations.price.notJson' && e.params.detail === 'no JSON (text/html, 20 bytes)' && e.httpStatus === 200);
});
});
it('beschrijft een foutantwoord dat geen JSON is in plaats van het weg te gooien', async () => {
const proxy = new Response('<html>Bad Gateway</html>', { status: 502, headers: { 'Content-Type': 'text/html' } });
await metFetch(proxy, async () => {
await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) =>
e.key === 'integrations.price.httpFailed' && e.params.detail === ' no JSON (text/html, 24 bytes)' && e.httpStatus === 502);
});
});
it('meldt een netwerkfout als netwerkfout en niet als koersfout', async () => {
const origineel = globalThis.fetch;
globalThis.fetch = async () => { throw new Error('kabel eruit'); };
try {
await assert.rejects(() => priceCall(p, 'spot', 'BTC', null, null), (e) => e.key === 'integrations.price.networkError');
} finally { globalThis.fetch = origineel; }
});
});
describe('failureEntry: wat het log van een mislukte koersaanroep krijgt', () => {
const p = provider({
authenticationKind: 'query_key', authenticationRequired: 1, authenticationParameterName: 'apikey',
endpoints: [endpoint('spot', '/v1/prijs')], outputs: [output('spot', 'price', 'data.eur')],
});
const fout = async (antwoord, creds = { api_key: 'GEHEIM123' }) => {
const origineel = globalThis.fetch;
globalThis.fetch = async () => antwoord;
try { await priceCall(p, 'spot', 'BTC', null, creds); } catch (e) { return e; } finally { globalThis.fetch = origineel; }
throw new Error('priceCall gooide niet');
};
it('zonder sleutel: "refused", geen tijd, en er ging niets de deur uit', async () => {
let gevraagd = false;
const origineel = globalThis.fetch;
globalThis.fetch = async () => { gevraagd = true; return new Response('{}'); };
let e;
try { await priceCall(p, 'spot', 'BTC', null, null); } catch (x) { e = x; } finally { globalThis.fetch = origineel; }
assert.equal(gevraagd, false);
const rij = failureEntry(p, 'spot', 'BTC', e, 12);
assert.deepEqual([rij.outcome, rij.latencyMs, rij.errorKey], ['refused', null, 'integrations.price.keyMissing']);
});
it('een foutstatus: "fail" met de status en de sleutel, en de capability als operatie en niet de URL', async () => {
const e = await fout(new Response(JSON.stringify({ error: 'bad key GEHEIM123' }), { status: 403 }));
const rij = failureEntry(p, 'spot', 'BTC', e, 12);
assert.deepEqual({ ...rij, detail: undefined }, {
target: 'provider', providerKey: 'test', operation: 'spot', subject: 'BTC', outcome: 'fail',
httpStatus: 403, errorKey: 'integrations.price.httpFailed', detail: undefined, latencyMs: 12,
});
assert.ok(!JSON.stringify(rij).includes('api.example.test'), 'de URL staat in de rij');
});
it('een antwoord zonder bruikbare prijs: "empty", met status 200', async () => {
const e = await fout(new Response(JSON.stringify({ data: { eur: null } }), { status: 200 }));
const rij = failureEntry(p, 'spot', 'BTC', e, 5);
assert.deepEqual([rij.outcome, rij.httpStatus, rij.errorKey], ['empty', 200, 'integrations.price.noValidPrice']);
});
});
describe('één bron voor crypto en metaal: het segment en de eenheid per symbool (migratie 99)', () => {
const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
const eulerpool = provider({
endpoints: [endpoint('daily_history', '/api/1/{external_id}/quotes/{symbol}')],
symbols: [{ symbol: 'BTC', externalId: 'crypto', quoteUnit: null }, { symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt' }],
outputs: [output('daily_history', 'time', '[].timestamp', 'ms_to_s'), output('daily_history', 'price', '[].price', 'last_per_day')],
});
const reeks = [{ timestamp: Date.parse('2026-09-01T00:00:00Z'), price: 3100 }];
async function vraag(symbool) {
const origineel = globalThis.fetch;
let url = null;
globalThis.fetch = async u => { url = u; return new Response(JSON.stringify(reeks), { status: 200 }); };
try { return { rijen: await priceCall(eulerpool, 'daily_history', symbool, null, null), url }; }
finally { globalThis.fetch = origineel; }
}
it('zet het segment van het symbool in het pad', async () => {
assert.match((await vraag('BTC')).url, /\/api\/1\/crypto\/quotes\/BTC$/);
assert.match((await vraag('XAU')).url, /\/api\/1\/commodity\/quotes\/XAU$/);
});
it('rekent alleen het symbool met ozt om naar per gram', async () => {
assert.equal((await vraag('BTC')).rijen[0].price, 3100, 'crypto blijft per munt');
assert.equal((await vraag('XAU')).rijen[0].price, 3100 / f, 'metaal wordt per gram');
});
});
describe('de catch-all in de invoermapping (migratie 102)', () => {
const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
const eulerpool = provider({
endpoints: [endpoint('daily_history', '/api/1/{external_id}/quotes/{symbol}')],
symbols: [{ symbol: 'XAU', externalId: 'commodity', quoteUnit: 'ozt', isCatchAll: 0 },
{ symbol: null, externalId: 'crypto', quoteUnit: null, isCatchAll: 1 }],
outputs: [output('daily_history', 'time', '[].timestamp', 'ms_to_s'), output('daily_history', 'price', '[].price', 'last_per_day')],
});
const reeks = [{ timestamp: Date.parse('2026-09-01T00:00:00Z'), price: 3100 }];
async function vraag(p, symbool) {
const origineel = globalThis.fetch;
let url = null;
globalThis.fetch = async u => { url = u; return new Response(JSON.stringify(reeks), { status: 200 }); };
try { return { rijen: await priceCall(p, 'daily_history', symbool, null, null), url }; }
finally { globalThis.fetch = origineel; }
}
it('geeft een munt zonder eigen regel het segment van de catch-all', async () => {
assert.match((await vraag(eulerpool, 'DOGE')).url, /\/api\/1\/crypto\/quotes\/DOGE$/);
assert.equal((await vraag(eulerpool, 'DOGE')).rijen[0].price, 3100, 'zonder eenheid op de catch-all blijft hij per munt');
});
it('laat een eigen regel voorgaan, ook in de eenheid', async () => {
const r = await vraag(eulerpool, 'XAU');
assert.match(r.url, /\/api\/1\/commodity\/quotes\/XAU$/);
assert.equal(r.rijen[0].price, 3100 / f);
});
it('valt zonder catch-all terug op het symbool zelf, zoals voorheen', async () => {
const zonder = { ...eulerpool, symbols: eulerpool.symbols.filter(s => !s.isCatchAll) };
assert.match((await vraag(zonder, 'DOGE')).url, /\/api\/1\/DOGE\/quotes\/DOGE$/);
});
});