571 lines
28 KiB
JavaScript
571 lines
28 KiB
JavaScript
// 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$/);
|
|
});
|
|
});
|