Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -0,0 +1,121 @@
|
||||
// Generated, do not edit. sha256:082ca8023bd277754ba77e02d92a8c8f7a15d0e14efa7674bf7905310f9ce692
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { diagnoseSources } from '../../shared/source-diagnosis.js';
|
||||
|
||||
const GOUD = { id: 7, symbol: 'XAU', kind: 'metal' };
|
||||
let id = 0;
|
||||
const bron = (key, extra = {}) => ({
|
||||
id: ++id, key, name: key.toUpperCase(), isActive: 1, isSystem: 0, fixedRate: null, callsPerMonth: null,
|
||||
coverage: ['XAU'], endpoints: [{ capability: 'daily_history' }], ...extra,
|
||||
});
|
||||
const perDatum = { endpoints: [{ capability: 'history_point' }] };
|
||||
|
||||
describe('diagnoseSources: de keten van voor naar achter', () => {
|
||||
it('geen integratie die het symbool dekt', () => {
|
||||
const uit = diagnoseSources(GOUD, { providers: [bron('mempool', { coverage: ['BTC'] })] });
|
||||
assert.deepEqual(uit, [{ severity: 'problem', code: 'noCoverage' }]);
|
||||
});
|
||||
|
||||
it('een systeembron of een vaste koers telt niet als dekking', () => {
|
||||
const uit = diagnoseSources(GOUD, { providers: [bron('manual', { isSystem: 1 }), bron('fixed', { fixedRate: { fiatPrice: 1 } })] });
|
||||
assert.equal(uit[0].code, 'noCoverage');
|
||||
});
|
||||
|
||||
it('een integratie zonder historie-endpoint telt niet als dekking', () => {
|
||||
const uit = diagnoseSources(GOUD, { providers: [bron('alleenspot', { endpoints: [{ capability: 'spot' }] })] });
|
||||
assert.equal(uit[0].code, 'noCoverage');
|
||||
});
|
||||
|
||||
it('dekking die uit staat noemt de namen', () => {
|
||||
const uit = diagnoseSources(GOUD, { providers: [bron('goldapi', { isActive: 0, ...perDatum })] });
|
||||
assert.deepEqual(uit, [{ severity: 'problem', code: 'allInactive', names: ['GOLDAPI'] }]);
|
||||
});
|
||||
|
||||
it('geen bronkeuze geeft een voorstel voor Historie, en een reeks gaat voor een bron per datum', () => {
|
||||
const goldapi = bron('goldapi', perDatum);
|
||||
const eulerpool = bron('eulerpool');
|
||||
const [f] = diagnoseSources(GOUD, { providers: [goldapi, eulerpool], historyChain: [] });
|
||||
assert.equal(f.code, 'noChoice');
|
||||
assert.equal(f.severity, 'problem');
|
||||
assert.deepEqual(f.proposal, { providerKey: 'eulerpool', providerId: eulerpool.id, providerName: 'EULERPOOL', capability: 'history' });
|
||||
});
|
||||
|
||||
it('een gekozen bron die uit staat is geen keuze, en het voorstel is de actieve', () => {
|
||||
const uit = bron('goldapi', { isActive: 0, ...perDatum });
|
||||
const aan = bron('eulerpool');
|
||||
const [f] = diagnoseSources(GOUD, { providers: [uit, aan], historyChain: ['goldapi'] });
|
||||
assert.equal(f.code, 'choiceUnusable');
|
||||
assert.deepEqual(f.names, ['GOLDAPI']);
|
||||
assert.equal(f.proposal.providerKey, 'eulerpool');
|
||||
});
|
||||
|
||||
it('een gekozen bron die het symbool niet dekt, is ook geen keuze', () => {
|
||||
const mempool = bron('mempool', { coverage: ['BTC'] });
|
||||
const [f] = diagnoseSources(GOUD, { providers: [mempool, bron('eulerpool')], historyChain: ['mempool'] });
|
||||
assert.equal(f.code, 'choiceUnusable');
|
||||
});
|
||||
|
||||
it('een gekozen bron zonder historie-endpoint is ook geen keuze', () => {
|
||||
const spot = bron('alleenspot', { endpoints: [{ capability: 'spot' }] });
|
||||
const [f] = diagnoseSources(GOUD, { providers: [spot, bron('eulerpool')], historyChain: ['alleenspot'] });
|
||||
assert.equal(f.code, 'choiceUnusable');
|
||||
});
|
||||
|
||||
it('een keten die op orde is geeft niets, met een reeks of met een bron per datum', () => {
|
||||
assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('eulerpool')], historyChain: ['eulerpool'] }), []);
|
||||
assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('goldapi', perDatum)], historyChain: ['goldapi'] }), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diagnoseSources: de maandlimiet en een uitgezette keuze', () => {
|
||||
it('alle gekozen bronnen op hun limiet is een probleem', () => {
|
||||
const g = bron('goldapi', { callsPerMonth: 100, ...perDatum });
|
||||
const [f] = diagnoseSources(GOUD, { providers: [g], historyChain: ['goldapi'], usage: { [g.id]: 100 } });
|
||||
assert.deepEqual(f, { severity: 'problem', code: 'limitReached', names: ['GOLDAPI'] });
|
||||
});
|
||||
|
||||
it('één van twee op de limiet is aandacht, want de keten valt door', () => {
|
||||
const g = bron('goldapi', { callsPerMonth: 100, ...perDatum });
|
||||
const e = bron('eulerpool');
|
||||
const [f] = diagnoseSources(GOUD, { providers: [g, e], historyChain: ['eulerpool', 'goldapi'], usage: { [g.id]: 120 } });
|
||||
assert.equal(f.severity, 'attention');
|
||||
assert.deepEqual(f.names, ['GOLDAPI']);
|
||||
});
|
||||
|
||||
it('onder de limiet is niets, en een bron zonder limiet telt nooit', () => {
|
||||
const g = bron('goldapi', { callsPerMonth: 100, ...perDatum });
|
||||
const e = bron('eulerpool');
|
||||
const uit = diagnoseSources(GOUD, { providers: [g, e], historyChain: ['eulerpool', 'goldapi'], usage: { [g.id]: 99, [e.id]: 5000 } });
|
||||
assert.deepEqual(uit, []);
|
||||
});
|
||||
|
||||
it('een gekozen bron die uit staat naast een werkende is aandacht', () => {
|
||||
const g = bron('goldapi', { isActive: 0, ...perDatum });
|
||||
const e = bron('eulerpool');
|
||||
const uit = diagnoseSources(GOUD, { providers: [g, e], historyChain: ['eulerpool', 'goldapi'] });
|
||||
assert.deepEqual(uit, [{ severity: 'attention', code: 'choiceInactive', names: ['GOLDAPI'] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diagnoseSources: de dienst levert de historie', () => {
|
||||
const dienst = { serviceCovers: true };
|
||||
|
||||
it('dekking die uit staat, geen dekking of geen keuze is dan geen melding', () => {
|
||||
assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('goldapi', { isActive: 0, ...perDatum })], ...dienst }), []);
|
||||
assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('mempool', { coverage: ['BTC'] })], ...dienst }), []);
|
||||
assert.deepEqual(diagnoseSources(GOUD, { providers: [bron('eulerpool')], historyChain: [], ...dienst }), []);
|
||||
});
|
||||
|
||||
it('alle gekozen bronnen op hun limiet is dan aandacht en geen probleem', () => {
|
||||
const g = bron('goldapi', { callsPerMonth: 100, ...perDatum });
|
||||
const [f] = diagnoseSources(GOUD, { providers: [g], historyChain: ['goldapi'], usage: { [g.id]: 100 }, ...dienst });
|
||||
assert.equal(f.severity, 'attention');
|
||||
});
|
||||
|
||||
it('zonder de dienst blijft het een probleem', () => {
|
||||
const uit = diagnoseSources(GOUD, { providers: [bron('goldapi', { isActive: 0, ...perDatum })], serviceCovers: false });
|
||||
assert.equal(uit[0].code, 'allInactive');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
// 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$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
// Generated, do not edit. sha256:be43673f9f978564f187426b8db28e08c641acba85e6d36a4aabd536adf07ca5
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
import { CONFIG } from '../../shared/config.js';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences', 'assets']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
|
||||
let STAND = 'off';
|
||||
mock.module('../../client/app-settings.js', {
|
||||
namedExports: { appSetting: (k) => (k === 'market_spot' ? STAND : 1), baseCurrency: () => 'EUR' },
|
||||
});
|
||||
|
||||
let DIENST = null;
|
||||
let dienstVragen = 0;
|
||||
let DAGEN = { status: 'notCovered' };
|
||||
let dagVragen = [];
|
||||
let DEKKING = { status: 'off' };
|
||||
mock.module('../../client/data/market.js', {
|
||||
namedExports: {
|
||||
serviceSpot: async () => { dienstVragen++; return DIENST; },
|
||||
intradaySeries: async () => ({ status: 'off' }),
|
||||
serviceDayHistory: async (assetId, since) => { dagVragen.push({ assetId, since }); return DAGEN; },
|
||||
serviceCoverage: async () => DEKKING,
|
||||
},
|
||||
});
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
|
||||
let bronVragen = 0;
|
||||
let bronWeg = false;
|
||||
globalThis.fetch = async () => {
|
||||
bronVragen++;
|
||||
if (bronWeg) throw new Error('bron weg');
|
||||
return { ok: true, status: 200, json: async () => ({ price: 100 }) };
|
||||
};
|
||||
|
||||
const { run, lastId, one } = harness.exports;
|
||||
let btc;
|
||||
|
||||
function systeemProvider(key, name) {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
|
||||
VALUES (?, ?, ?, '', 'none', 0, 1, 1, 1, '2026-10-09')`, [key, name, name.toLowerCase()]);
|
||||
return lastId();
|
||||
}
|
||||
function eigenBron({ key = 'bron', name = 'Eigen bron', rank = 1, status = 'confirmed' } = {}) {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
|
||||
VALUES (?, ?, ?, 'https://voorbeeld.test', 'none', 0, 1, 1, 0, '2026-10-09')`, [key, name, name.toLowerCase()]);
|
||||
const id = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [id]);
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) VALUES (?, 'spot', 'GET', '/p', NULL)`, [id]);
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) VALUES (?, 'spot', ?, ?, ?)`, [btc, id, rank, status]);
|
||||
}
|
||||
const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [btc]);
|
||||
const ok = (price, currency = 'EUR') => ({ status: 'ok', instrument: 'crypto:BTC', price, currency, observedAt: 1, source: 'bitvavo', sourceLabel: 'Bitvavo' });
|
||||
const gecached = () => one(`SELECT fiatPrice FROM PriceHistory JOIN Provider ON Provider.id = PriceHistory.providerId
|
||||
WHERE Provider.key = 'atreides_services' AND assetId = ?`, [btc]);
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.clearSpotCache();
|
||||
STAND = 'off';
|
||||
DIENST = ok(61000);
|
||||
dienstVragen = 0;
|
||||
DAGEN = { status: 'notCovered' };
|
||||
dagVragen = [];
|
||||
DEKKING = { status: 'off' };
|
||||
facade.forgetSeriesFetches();
|
||||
bronVragen = 0;
|
||||
bronWeg = false;
|
||||
systeemProvider('atreides_services', 'Atreides Services');
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
|
||||
btc = lastId();
|
||||
});
|
||||
|
||||
describe('de stand van market_spot', () => {
|
||||
it('uit: de dienst wordt NIET gevraagd, ook als de eigen keten niets geeft', async () => {
|
||||
assert.equal(await facade.liveSpotFor(asset()), null);
|
||||
assert.equal(dienstVragen, 0);
|
||||
});
|
||||
|
||||
it('als terugval: een werkende eigen bron gaat voor en de dienst wordt niet gevraagd', async () => {
|
||||
STAND = 'fallback';
|
||||
eigenBron();
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 });
|
||||
assert.equal(dienstVragen, 0);
|
||||
assert.equal(facade.lastSpotSource(asset()), 'Eigen bron');
|
||||
});
|
||||
|
||||
it('als terugval: zonder eigen bron levert de dienst, en die koers komt in de cache', async () => {
|
||||
STAND = 'fallback';
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 });
|
||||
assert.equal(facade.lastSpotSource(asset()), 'Atreides Services (Bitvavo)');
|
||||
assert.equal(gecached().fiatPrice, 61000);
|
||||
});
|
||||
|
||||
it('eerst: de dienst gaat vóór een bron die de APP zette', async () => {
|
||||
STAND = 'first';
|
||||
eigenBron();
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 });
|
||||
assert.equal(bronVragen, 0, 'de automatische bron wordt dan niet gevraagd');
|
||||
});
|
||||
|
||||
it('eerst: een bron die de GEBRUIKER koos gaat vóór de dienst', async () => {
|
||||
STAND = 'first';
|
||||
eigenBron({ status: 'manual' });
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 });
|
||||
assert.equal(dienstVragen, 0);
|
||||
});
|
||||
|
||||
it('eerst: zelf gekozen bron weg, dan de dienst, en pas daarna de bron van de app', async () => {
|
||||
STAND = 'first';
|
||||
eigenBron({ key: 'zelf', name: 'Zelf', rank: 1, status: 'manual' });
|
||||
eigenBron({ key: 'app', name: 'App', rank: 2, status: 'confirmed' });
|
||||
bronWeg = true;
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 });
|
||||
assert.equal(bronVragen, 1, 'alleen de zelf gekozen bron is geprobeerd, de bron van de app niet');
|
||||
});
|
||||
|
||||
it('eerst, dienst weg: na de zelf gekozen bron komt de bron van de app aan de beurt', async () => {
|
||||
STAND = 'first';
|
||||
eigenBron({ key: 'app', name: 'App', status: 'confirmed' });
|
||||
DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} };
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 });
|
||||
assert.equal(facade.lastSpotSource(asset()), 'App');
|
||||
});
|
||||
});
|
||||
|
||||
describe('een dienst die even weg is', () => {
|
||||
it('eerst, dienst weg: de eigen bron neemt het over', async () => {
|
||||
STAND = 'first';
|
||||
eigenBron();
|
||||
DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} };
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 });
|
||||
});
|
||||
|
||||
it('dienst weg en geen eigen bron: de laatst bekende koers uit de cache, en die zegt dat', async () => {
|
||||
STAND = 'first';
|
||||
await facade.liveSpotFor(asset());
|
||||
facade.clearSpotCache();
|
||||
DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} };
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 });
|
||||
assert.match(facade.lastSpotSource(asset()), /laatst bekend/);
|
||||
});
|
||||
|
||||
it('een gat (missing) leest niet uit de cache: dan is er echt geen koers', async () => {
|
||||
STAND = 'first';
|
||||
await facade.liveSpotFor(asset());
|
||||
facade.clearSpotCache();
|
||||
DIENST = { status: 'missing' };
|
||||
assert.equal(await facade.liveSpotFor(asset()), null);
|
||||
});
|
||||
|
||||
it('eigen bron weg en dienst weg: eerst de eigen bron geprobeerd, dan de cache', async () => {
|
||||
STAND = 'fallback';
|
||||
eigenBron();
|
||||
bronWeg = true;
|
||||
DIENST = ok(61000);
|
||||
await facade.liveSpotFor(asset());
|
||||
facade.clearSpotCache();
|
||||
DIENST = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} };
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 61000 });
|
||||
assert.ok(bronVragen >= 2, 'de eigen bron kreeg zijn beurt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('het statuslampje bij de dienst', () => {
|
||||
const metDocument = async (fn) => {
|
||||
const events = [];
|
||||
globalThis.document = { dispatchEvent: e => { events.push(e.detail); return true; } };
|
||||
try { await fn(); } finally { delete globalThis.document; }
|
||||
return events.filter(d => d.provider_key === 'atreides_services');
|
||||
};
|
||||
|
||||
it('licht op bij een spot uit de dienst, met het symbool', async () => {
|
||||
STAND = 'first';
|
||||
const events = await metDocument(() => facade.liveSpotFor(asset()));
|
||||
assert.equal(events.length, 1);
|
||||
assert.deepEqual([events[0].ok, events[0].symbol, events[0].capability], [true, 'BTC', 'spot']);
|
||||
});
|
||||
|
||||
it('kleurt rood als de dienst weg is, en zegt niets in de stand uit', async () => {
|
||||
STAND = 'first';
|
||||
DIENST = { status: 'unavailable', retryLater: true };
|
||||
const weg = await metDocument(() => facade.liveSpotFor(asset()));
|
||||
assert.equal(weg[0]?.ok, false);
|
||||
facade.clearSpotCache();
|
||||
STAND = 'off';
|
||||
assert.deepEqual(await metDocument(() => facade.liveSpotFor(asset())), []);
|
||||
});
|
||||
|
||||
it('licht ook op bij dagkoersen uit de dienst', async () => {
|
||||
STAND = 'first';
|
||||
DAGEN = { status: 'ok', instrument: 'crypto:BTC', currency: 'EUR', source: 'eulerpool', days: [[Date.now() - 2 * 86_400_000, 60000]] };
|
||||
const events = await metDocument(() => facade.ensurePriceHistoryFor(asset()));
|
||||
assert.equal(events.some(d => d.capability === 'daily_history' && d.ok), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('de munt van de bron', () => {
|
||||
it('rekent om naar de rekenvaluta met de koers die elke spot krijgt', async () => {
|
||||
STAND = 'first';
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`);
|
||||
const usd = lastId();
|
||||
const vast = systeemProvider('fixed', 'Vaste koers');
|
||||
const vandaag = new Date().toISOString().slice(0, 10);
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 0.8, 'day', ?)`,
|
||||
[usd, vast, `${vandaag}T00:00:00Z`]);
|
||||
DIENST = ok(70000, 'USD');
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 56000 });
|
||||
});
|
||||
|
||||
it('zonder wisselkoers geen koers, in plaats van dollars als euro\'s', async () => {
|
||||
STAND = 'first';
|
||||
DIENST = ok(70000, 'USD');
|
||||
assert.equal(await facade.liveSpotFor(asset()), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dagkoersen uit de dienst', () => {
|
||||
const DAG = 86_400_000;
|
||||
const vandaag = () => Date.parse(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
|
||||
const dagen = (...prijzen) => ({ status: 'ok', instrument: 'crypto:BTC', currency: 'EUR', source: 'eulerpool',
|
||||
days: prijzen.map(([terug, p]) => [vandaag() - terug * DAG, p]) });
|
||||
const opgeslagen = () => harness.exports.all(`SELECT substr(observedAt, 1, 10) AS dag, fiatPrice FROM PriceHistory
|
||||
JOIN Provider ON Provider.id = PriceHistory.providerId
|
||||
WHERE Provider.key = 'atreides_services' AND assetId = ? AND resolution = 'day' ORDER BY observedAt`, [btc]);
|
||||
const dagVan = (terug) => new Date(vandaag() - terug * DAG).toISOString().slice(0, 10);
|
||||
|
||||
it('uit: de dienst wordt niet gevraagd', async () => {
|
||||
DAGEN = dagen([1, 60000]);
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 0);
|
||||
});
|
||||
|
||||
it('eerst: de dagen van de dienst komen in de cache, en één keer per dag', async () => {
|
||||
STAND = 'first';
|
||||
DAGEN = dagen([2, 59000], [1, 60000]);
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.deepEqual(opgeslagen(), [{ dag: dagVan(2), fiatPrice: 59000 }, { dag: dagVan(1), fiatPrice: 60000 }]);
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 1, 'twee keer gevraagd op één dag');
|
||||
});
|
||||
|
||||
it('vraagt vanaf de dag na de nieuwste afgesloten dag, en niets als gisteren er al is', async () => {
|
||||
STAND = 'first';
|
||||
const vast = systeemProvider('fixed', 'Vaste koers');
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 1, 'day', ?)`,
|
||||
[btc, vast, `${dagVan(5)}T00:00:00Z`]);
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 1, 'day', ?)`,
|
||||
[btc, vast, `${dagVan(0)}T00:00:00Z`]);
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen[0].since, vandaag() - 4 * DAG);
|
||||
|
||||
facade.forgetSeriesFetches();
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 1, 'day', ?)`,
|
||||
[btc, vast, `${dagVan(1)}T00:00:00Z`]);
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 1);
|
||||
});
|
||||
|
||||
it('rekent elke dag om met de koers van DIE dag, en laat een dag zonder koers weg', async () => {
|
||||
STAND = 'first';
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`);
|
||||
const usd = lastId();
|
||||
const vast = systeemProvider('fixed', 'Vaste koers');
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 0.9, 'day', ?)`,
|
||||
[usd, vast, `${dagVan(20)}T00:00:00Z`]);
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt) VALUES (?, ?, 0.8, 'day', ?)`,
|
||||
[usd, vast, `${dagVan(2)}T00:00:00Z`]);
|
||||
DAGEN = { ...dagen([30, 1000], [12, 1000], [1, 1000]), currency: 'USD' };
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.deepEqual(opgeslagen(), [{ dag: dagVan(1), fiatPrice: 800 }]);
|
||||
});
|
||||
|
||||
it('eerst: een reeksbron die de gebruiker zelf koos gaat voor, en de dienst wordt niet gevraagd', async () => {
|
||||
STAND = 'first';
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('zelf', 'Zelf', 'zelf', 'https://voorbeeld.test', 'none', 0, 1, 1, 0, '2026-10-09')`);
|
||||
const id = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [id]);
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate) VALUES (?, 'daily_history', 'GET', '/d', NULL)`, [id]);
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status) VALUES (?, 'history', ?, 1, 'manual')`, [btc, id]);
|
||||
DAGEN = dagen([1, 60000]);
|
||||
try { await facade.ensurePriceHistoryFor(asset()); } catch { }
|
||||
assert.equal(dagVragen.length, 0);
|
||||
});
|
||||
|
||||
it('Gaten opvullen: de dienst vult het gat, alleen binnen het gat, en niets in stand uit', async () => {
|
||||
DAGEN = dagen([12, 100], [10, 101], [9, 102], [3, 103]);
|
||||
const uit = await facade.fillClosesFor(asset(), dagVan(11), dagVan(9));
|
||||
assert.equal(dagVragen.length, 0, 'stand uit vroeg de dienst');
|
||||
assert.equal(uit.written, 0);
|
||||
|
||||
STAND = 'fallback';
|
||||
const met = await facade.fillClosesFor(asset(), dagVan(11), dagVan(9));
|
||||
assert.equal(met.written, 2);
|
||||
assert.deepEqual(opgeslagen(), [{ dag: dagVan(10), fiatPrice: 101 }, { dag: dagVan(9), fiatPrice: 102 }]);
|
||||
});
|
||||
|
||||
it('een weigering wordt onthouden: een tweede omrekening vraagt de dienst niet opnieuw', async () => {
|
||||
STAND = 'first';
|
||||
DAGEN = { status: 'unavailable', retryLater: false, key: 'marketService.rejected', params: { status: 404, key: 'api.noSeries' } };
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 1, 'de weigering is opnieuw gevraagd');
|
||||
|
||||
facade.forgetSeriesFetches();
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 2);
|
||||
});
|
||||
|
||||
it('later opnieuw: pas na een korte pauze, niet bij elke weergave', async () => {
|
||||
STAND = 'first';
|
||||
DAGEN = { status: 'unavailable', retryLater: true, key: 'marketService.unreachable', params: {} };
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 1, 'binnen de pauze opnieuw gevraagd');
|
||||
|
||||
const echt = Date.now;
|
||||
Date.now = () => echt() + CONFIG.MARKET_SERVICE.RETRY_PAUSE_MS + 1;
|
||||
try { await facade.ensurePriceHistoryFor(asset()); } finally { Date.now = echt; }
|
||||
assert.equal(dagVragen.length, 2, 'na de pauze niet opnieuw gevraagd');
|
||||
});
|
||||
|
||||
it('als terugval: een werkende eigen bron gaat voor; zonder eigen bron de dienst', async () => {
|
||||
STAND = 'fallback';
|
||||
eigenBron();
|
||||
DAGEN = dagen([1, 60000]);
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 0);
|
||||
|
||||
harness.exports.run(`DELETE FROM PriceSource`);
|
||||
harness.exports.run(`DELETE FROM PriceHistory`);
|
||||
facade.forgetSeriesFetches();
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(dagVragen.length, 1);
|
||||
assert.equal(opgeslagen().length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rateSourceFor: de dienst als wisselkoersbron', () => {
|
||||
const dollar = () => { run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`); return lastId(); };
|
||||
|
||||
it('telt mee als de stand niet uit staat en de dienst de munt dekt', async () => {
|
||||
const usd = dollar();
|
||||
DEKKING = { status: 'ok', assetIds: [usd] };
|
||||
for (const stand of ['first', 'fallback']) {
|
||||
STAND = stand;
|
||||
assert.equal((await facade.rateSourceFor('USD'))?.key, 'atreides_services', stand);
|
||||
}
|
||||
});
|
||||
|
||||
it('telt niet mee in de stand uit, voor een munt die hij niet dekt, of als hij weg is', async () => {
|
||||
const usd = dollar();
|
||||
DEKKING = { status: 'ok', assetIds: [usd] };
|
||||
assert.equal(await facade.rateSourceFor('USD'), null, 'stand uit');
|
||||
STAND = 'first';
|
||||
DEKKING = { status: 'ok', assetIds: [btc] };
|
||||
assert.equal(await facade.rateSourceFor('USD'), null, 'niet gedekt');
|
||||
DEKKING = { status: 'unavailable' };
|
||||
assert.equal(await facade.rateSourceFor('USD'), null, 'onbereikbaar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Generated, do not edit. sha256:332bac6d5d6fd8fc6489b8ba93b57adc4f35577ee5444684f9169fb9444c227e
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
import { rekenvalutaUitDatabase } from '../helpers/rekenvaluta.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'assets', 'tx-links']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
await rekenvalutaUitDatabase(mock);
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
const fiat = await import('../../client/ui/fiat.js');
|
||||
|
||||
let fetchCalls = 0;
|
||||
globalThis.fetch = async () => { fetchCalls++; return { ok: true, status: 200, json: async () => ({ price: 95000 }) }; };
|
||||
|
||||
const { run, lastId, one } = harness.exports;
|
||||
let btc, bron;
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.clearSpotCache();
|
||||
fetchCalls = 0;
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
|
||||
btc = lastId();
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('bitvavo','Bitvavo','bitvavo','https://voorbeeld.test','none',0,1,1,0,'2026-01-01T00:00:00Z')`);
|
||||
bron = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'BTC')`, [bron]);
|
||||
for (const cap of ['spot', 'daily_history']) {
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, ?, 'GET', '/x')`, [bron, cap]);
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, ?, ?, 1, 'confirmed')`, [btc, cap === 'spot' ? 'spot' : 'history', bron]);
|
||||
}
|
||||
});
|
||||
const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [btc]);
|
||||
|
||||
describe('fiatHistoricalOrigin: de dagkoers onder een verplaatsing', () => {
|
||||
it('beschrijft de rij waarmee de waarde gerekend is, met zijn bron', async () => {
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt, createdAt)
|
||||
VALUES (?, ?, 80000, 'day', '2026-03-03T00:00:00Z', '2026-03-04T00:00:00Z')`, [btc, bron]);
|
||||
await fiat.loadHistorical([{ assetId: btc, dateKey: '2026-03-03' }]);
|
||||
assert.notEqual(fiat.fiatHistorical(1e8, asset(), '2026-03-03'), '', 'er is een waarde');
|
||||
const o = fiat.fiatHistoricalOrigin(asset(), '2026-03-03');
|
||||
assert.deepEqual({ kind: o.kind, fiatPrice: o.fiatPrice, source: o.source, day: o.day }, { kind: 'close', fiatPrice: 80000, source: 'Bitvavo', day: '2026-03-03' });
|
||||
assert.equal(fetchCalls, 0, 'de koers stond er al, dus er ging niets naar buiten');
|
||||
});
|
||||
|
||||
it('heeft geen herkomst zonder koers, en ook niet voor de euro zelf', () => {
|
||||
assert.equal(fiat.fiatHistoricalOrigin(asset(), '2020-01-01'), null);
|
||||
assert.equal(fiat.fiatHistoricalOrigin({ id: 99, symbol: 'EUR' }, '2026-03-03'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fiatValueOrigin: de spot onder een bedrag op Tracking', () => {
|
||||
it('is live en noemt de bron die de spot gaf', async () => {
|
||||
await fiat.loadFiat([asset()]);
|
||||
const o = fiat.fiatValueOrigin(asset());
|
||||
assert.deepEqual({ kind: o.kind, fiatPrice: o.fiatPrice, source: o.source }, { kind: 'live', fiatPrice: 95000, source: 'Bitvavo' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Generated, do not edit. sha256:80f49e74154e65b0c30e365129d8a9f192b6481371b78383fd6a55befe29b11a
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
|
||||
let fetchCalls = [];
|
||||
let antwoord = { price: 100 };
|
||||
globalThis.fetch = async (url, opties) => {
|
||||
fetchCalls.push({ url, body: opties?.body });
|
||||
return { ok: true, status: 200, json: async () => antwoord };
|
||||
};
|
||||
|
||||
const { run, lastId, one } = harness.exports;
|
||||
let goud;
|
||||
|
||||
function provider(key, { capability = 'spot', interval = null } = {}) {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,0,'2026-09-21T10:00:00.000Z')`, [key, key, key]);
|
||||
const id = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [id]);
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, minIntervalSeconds)
|
||||
VALUES (?, ?, 'GET', '/prijs', NULL, ?)`, [id, capability, interval]);
|
||||
return id;
|
||||
}
|
||||
function bronkeuze(providerId, capability = 'spot') {
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, ?, ?, 1, 'confirmed')`, [goud, capability === 'spot' ? 'spot' : 'history', providerId]);
|
||||
}
|
||||
function spotRij(providerId, minutenGeleden, eur) {
|
||||
const moment = new Date(Date.now() - minutenGeleden * 60000).toISOString();
|
||||
run(`INSERT INTO PriceHistory (assetId, providerId, fiatPrice, resolution, observedAt, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, 'timestamp', ?, ?, ?)`, [goud, providerId, eur, moment, moment, moment]);
|
||||
}
|
||||
const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [goud]);
|
||||
const dag = n => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.clearSpotCache();
|
||||
facade.forgetSpotAnswers();
|
||||
facade.forgetCallTimes();
|
||||
fetchCalls = [];
|
||||
antwoord = { price: 100 };
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`);
|
||||
goud = lastId();
|
||||
});
|
||||
|
||||
describe('het interval van een spot', () => {
|
||||
it('vraagt binnen het interval niet opnieuw, ook niet na de tik van de app', async () => {
|
||||
bronkeuze(provider('goldapi', { interval: 600 }));
|
||||
await facade.liveSpotFor(asset());
|
||||
facade.clearSpotCache();
|
||||
antwoord = { price: 999 };
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 }, 'het bewaarde antwoord');
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
});
|
||||
|
||||
it('vraagt zonder interval na de tik wél opnieuw, zoals voorheen', async () => {
|
||||
bronkeuze(provider('bron'));
|
||||
await facade.liveSpotFor(asset());
|
||||
facade.clearSpotCache();
|
||||
await facade.liveSpotFor(asset());
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
});
|
||||
|
||||
it('overleeft een herstart via de laatste spotrij van die bron', async () => {
|
||||
const p = provider('goldapi', { interval: 600 });
|
||||
bronkeuze(p);
|
||||
spotRij(p, 5, 123);
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 123 });
|
||||
assert.equal(fetchCalls.length, 0, 'geen verzoek');
|
||||
});
|
||||
|
||||
it('vraagt wél opnieuw als die rij ouder is dan het interval', async () => {
|
||||
const p = provider('goldapi', { interval: 600 });
|
||||
bronkeuze(p);
|
||||
spotRij(p, 20, 123);
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 100 });
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
});
|
||||
|
||||
it('telt de spotrij van een ándere bron niet mee', async () => {
|
||||
const p = provider('goldapi', { interval: 600 });
|
||||
const ander = provider('metalsdev');
|
||||
bronkeuze(p);
|
||||
spotRij(ander, 1, 123);
|
||||
await facade.liveSpotFor(asset());
|
||||
assert.equal(fetchCalls.length, 1, 'die rij zegt niets over wanneer goldapi gevraagd is');
|
||||
});
|
||||
});
|
||||
|
||||
describe('het interval van een koers per datum', () => {
|
||||
it('wacht het interval af tussen twee verzoeken', async () => {
|
||||
provider('perdatum', { capability: 'history_point', interval: 1 });
|
||||
antwoord = { time: 1, price: 100 };
|
||||
const t0 = Date.now();
|
||||
await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-3));
|
||||
await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-2));
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
assert.ok(Date.now() - t0 >= 900, `het tweede verzoek wachtte (${Date.now() - t0} ms)`);
|
||||
});
|
||||
|
||||
it('wacht niet zonder interval', async () => {
|
||||
provider('perdatum', { capability: 'history_point' });
|
||||
antwoord = { time: 1, price: 100 };
|
||||
const t0 = Date.now();
|
||||
await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-3));
|
||||
await facade.fetchPriceVia('perdatum', asset(), 'history_point', dag(-2));
|
||||
assert.ok(Date.now() - t0 < 500);
|
||||
});
|
||||
|
||||
it('breekt het wachten af met de stopknop, zonder verzoek', async () => {
|
||||
bronkeuze(provider('perdatum', { capability: 'history_point', interval: 3600 }), 'history_point');
|
||||
antwoord = { time: 1, price: 100 };
|
||||
const stop = new AbortController();
|
||||
const t0 = Date.now();
|
||||
setTimeout(() => stop.abort(), 100);
|
||||
const uit = await facade.fillClosesFor(asset(), dag(-7), dag(-1), { signal: stop.signal });
|
||||
assert.equal(uit.stopped, true);
|
||||
assert.equal(fetchCalls.length, 1, 'alleen het eerste verzoek ging weg');
|
||||
assert.ok(Date.now() - t0 < 2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Generated, do not edit. sha256:51c49028629aebea324a581fb9c02f64b8a220708011bb4270da71cc94da3e61
|
||||
import { describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
let ANTWOORD = null;
|
||||
mock.module('../../client/data/market.js', { namedExports: { intradaySeries: async () => ANTWOORD } });
|
||||
mock.module('../../client/price/providers.js', { namedExports: { currentRate: async () => 2 } });
|
||||
|
||||
const { loadIntraday } = await import('../../client/price/intraday.js');
|
||||
const { t } = await import('../../client/i18n/index.js');
|
||||
|
||||
describe('loadIntraday: de bron per punt', () => {
|
||||
it('maakt van een eigen bron per punt een label, en rekent de prijs om', async () => {
|
||||
ANTWOORD = { status: 'ok', currency: 'USD', source: 'mempool',
|
||||
points: [[1000, 10, 'bitvavo'], [2000, 11, 'mempool'], [3000, 12]] };
|
||||
const r = await loadIntraday(1, 0);
|
||||
assert.deepEqual(r.points, [[1000, 20], [2000, 22], [3000, 24]]);
|
||||
assert.equal(r.source, t('marketService.sourceLive', { source: 'mempool' }));
|
||||
assert.equal(r.sources.get(1000), t('marketService.sourceLive', { source: 'bitvavo' }));
|
||||
assert.equal(r.sources.get(2000), t('marketService.sourceLive', { source: 'mempool' }));
|
||||
assert.equal(r.sources.has(3000), false, 'zonder eigen bron valt het punt terug op de bron van de reeks');
|
||||
});
|
||||
|
||||
it('een reeks zonder bron per rij geeft een lege kaart', async () => {
|
||||
ANTWOORD = { status: 'ok', currency: 'USD', source: 'bitvavo', points: [[1000, 10]] };
|
||||
const r = await loadIntraday(2, 0);
|
||||
assert.equal(r.sources.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadIntraday: zonder reeks geen melding', () => {
|
||||
const gevallen = [
|
||||
['de dienst is even weg', { status: 'unavailable', retryLater: true }],
|
||||
['de dienst weigert', { status: 'unavailable', key: 'marketService.unauthorized' }],
|
||||
['de app gebruikt de dienst niet', { status: 'off' }],
|
||||
];
|
||||
for (const [i, [naam, antwoord]] of gevallen.entries()) {
|
||||
it(naam, async () => {
|
||||
ANTWOORD = antwoord;
|
||||
assert.deepEqual({ ...(await loadIntraday(10 + i, 0)) }, { points: null });
|
||||
});
|
||||
}
|
||||
|
||||
it('de aanroep gooit', async () => {
|
||||
ANTWOORD = null;
|
||||
assert.deepEqual({ ...(await loadIntraday(20, 0)) }, { points: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generated, do not edit. sha256:70b6740879727acfaef00d88148ac04c728d9809f7bdb7b319265eea0e2e9de7
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { nwcMemo } = await import('../../client/lightning/lightning-tracker.js');
|
||||
const { memoLine } = await import('../../client/lightning/memo-line.js');
|
||||
|
||||
const ALICE = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
|
||||
const zap = { kind: 9734, pubkey: ALICE, content: 'Top', tags: [['p', 'a'.repeat(64)]] };
|
||||
|
||||
describe('nwcMemo', () => {
|
||||
it('neemt de omschrijving, en anders het zap-verzoek uit metadata.nostr', () => {
|
||||
assert.equal(nwcMemo({ description: 'Koffie' }), 'Koffie');
|
||||
assert.deepEqual(nwcMemo({ description: 'b'.repeat(64), metadata: { nostr: { pubkey: ALICE, content: 'Top', tags: [] } } }),
|
||||
{ kind: 9734, pubkey: ALICE, content: 'Top', tags: [] });
|
||||
assert.equal(nwcMemo({ description: JSON.stringify(zap), metadata: { nostr: { pubkey: 'x' } } }), JSON.stringify(zap));
|
||||
assert.equal(nwcMemo({ metadata: { comment: 'Bedankt' } }), 'Bedankt');
|
||||
assert.equal(nwcMemo({}), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoLine', () => {
|
||||
it('noemt bij binnenkomend de afzender met het bericht', () => {
|
||||
assert.deepEqual(memoLine(JSON.stringify(zap), 'in'), { text: 'Zap van npub180cv…h6w6: "Top"', title: 'Zap van npub180cv…h6w6: "Top"' });
|
||||
});
|
||||
|
||||
it('noemt bij uitgaand de ontvanger, en kort een lange regel in met de hele tekst als titel', () => {
|
||||
const lang = { ...zap, content: 'x'.repeat(200) };
|
||||
const r = memoLine(JSON.stringify(lang), 'out', 40);
|
||||
assert.match(r.text, /^Zap aan npub1/);
|
||||
assert.equal(r.text.length, 40);
|
||||
assert.ok(r.title.length > 200);
|
||||
});
|
||||
|
||||
it('een gewone memo komt zoals hij is, en geen memo geeft niets', () => {
|
||||
assert.equal(memoLine('Koffie', 'out').text, 'Koffie');
|
||||
assert.equal(memoLine(null, 'in'), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Generated, do not edit. sha256:1e50493a927118d3db6e1a4a85d1b998ed506b6b294e4b4ef8436e2e3deb698d
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const geschreven = { rijen: [], saldo: [], labels: [] };
|
||||
mock.module('../../client/data/wallet-txs.js', { namedExports: {
|
||||
upsertWalletTxs: async (rows) => { geschreven.rijen.push(...rows); },
|
||||
labelChannelTxs: async (accountId, txids) => { geschreven.labels.push([accountId, txids]); return txids.length; },
|
||||
} });
|
||||
mock.module('../../client/data/wallet-sources.js', { namedExports: { recordSourceBalance: async (id, sats) => { geschreven.saldo.push([id, sats]); } } });
|
||||
let KANALEN = { open: [], closed: [] };
|
||||
mock.module('../../client/lightning/lnd.js', { namedExports: {
|
||||
listChannels: async () => ({ channels: KANALEN.open }),
|
||||
listClosedChannels: async () => ({ channels: KANALEN.closed }),
|
||||
} });
|
||||
|
||||
const { walletTxRows, scanLightningSource, lightningAccountSats, isLightningSource, countMovements } =
|
||||
await import('../../client/lightning/lightning-scan.js');
|
||||
const { channelTxids } = await import('../../client/lightning/lightning-tracker.js');
|
||||
|
||||
beforeEach(() => { geschreven.rijen = []; geschreven.saldo = []; geschreven.labels = []; });
|
||||
|
||||
const BRON = { id: 4, accountId: 2, kind: 'lnd', connectionId: 9 };
|
||||
const AT = '2026-10-19T10:00:00.000Z';
|
||||
|
||||
describe('walletTxRows', () => {
|
||||
it('maakt van een betaling, een factuur en een forward elk een lightning-rij met een sleutel', () => {
|
||||
const { rows, skipped } = walletTxRows([
|
||||
{ hash: 'aa', direction: 'out', sats: 21, fee_sats: 1, at: AT },
|
||||
{ hash: 'bb', direction: 'in', sats: 10_000, fee_sats: 0, at: AT },
|
||||
{ hash: null, ref: 'fwd:1:2:3', direction: 'route', sats: 1, fee_sats: 0, at: AT },
|
||||
], BRON);
|
||||
assert.equal(skipped, 0);
|
||||
assert.deepEqual(rows.map(r => [r.txid, r.direction, r.valueSats, r.feeSats]),
|
||||
[['aa', 'out', 21, 1], ['bb', 'in', 10_000, null], ['fwd:1:2:3', 'in', 1, null]]);
|
||||
for (const r of rows) {
|
||||
assert.deepEqual([r.layer, r.accountId, r.sourceId, r.blockTime, r.blockHeight], ['lightning', 2, 4, AT, null]);
|
||||
}
|
||||
});
|
||||
|
||||
it('een betaling aan uzelf krijgt de soort self, een gewone geen', () => {
|
||||
const { rows } = walletTxRows([
|
||||
{ hash: 'aa', direction: 'out', sats: 5_000, fee_sats: 3, at: AT, self: true },
|
||||
{ hash: 'bb', direction: 'out', sats: 21, fee_sats: 1, at: AT, self: false },
|
||||
], BRON);
|
||||
assert.deepEqual(rows.map(r => [r.txid, r.kind]), [['aa', 'self'], ['bb', null]]);
|
||||
});
|
||||
|
||||
it('laat een beweging zonder sleutel of zonder moment weg, en telt hem', () => {
|
||||
const { rows, skipped } = walletTxRows([
|
||||
{ hash: null, direction: 'in', sats: 1, at: AT },
|
||||
{ hash: 'cc', direction: 'in', sats: 1, at: null },
|
||||
], BRON);
|
||||
assert.deepEqual([rows.length, skipped], [0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanLightningSource', () => {
|
||||
const tracker = {
|
||||
normalizeMovements: async () => [{ hash: 'aa', direction: 'out', sats: 21, fee_sats: 1, at: AT }],
|
||||
normalizeNwcMovements: async () => [{ hash: 'aa', direction: 'out', sats: 21, fee_sats: 1, at: AT }],
|
||||
nodeBalance: async () => ({ channelLocal: 250_000, anchor: 900_000 }),
|
||||
channelTxids: async () => new Set(['f'.repeat(64)]),
|
||||
};
|
||||
|
||||
it('LND: de bewegingen in de cache, de kanaal-outbound als saldo, en het kanaallabel op het account', async () => {
|
||||
const r = await scanLightningSource(BRON, { tracker });
|
||||
assert.deepEqual(r, { count: 1, skipped: 0, balanceSats: 250_000, channelLabels: 1, channelTxids: ['f'.repeat(64)] });
|
||||
assert.equal(geschreven.rijen[0].txid, 'aa');
|
||||
assert.deepEqual(geschreven.saldo, [[4, 250_000]]);
|
||||
assert.deepEqual(geschreven.labels, [[2, ['f'.repeat(64)]]]);
|
||||
});
|
||||
|
||||
it('NWC: het eigen saldo van de wallet, van msat naar hele sats', async () => {
|
||||
const r = await scanLightningSource({ id: 7, accountId: 2, kind: 'nwc' },
|
||||
{ tracker, nwcClient: { getBalance: async () => ({ balance: 21_999 }) } });
|
||||
assert.equal(r.balanceSats, 21);
|
||||
assert.deepEqual(geschreven.saldo, [[7, 21]]);
|
||||
});
|
||||
|
||||
it('een node die niet antwoordt: een fout, en er wordt niets geschreven', async () => {
|
||||
const kapot = { ...tracker, normalizeMovements: async () => { throw new Error('node weg'); } };
|
||||
await assert.rejects(scanLightningSource(BRON, { tracker: kapot }), /node weg/);
|
||||
assert.deepEqual([geschreven.rijen.length, geschreven.saldo.length], [0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channelTxids', () => {
|
||||
const A = 'a'.repeat(64), B = 'b'.repeat(64), C = 'c'.repeat(64);
|
||||
|
||||
it('neemt de funding-tx van open en gesloten kanalen, en de closing-tx van een gesloten', async () => {
|
||||
KANALEN = { open: [{ channel_point: `${A}:0` }], closed: [{ channel_point: `${B.toUpperCase()}:1`, closing_tx_hash: C }] };
|
||||
assert.deepEqual([...await channelTxids(9)].sort(), [A, B, C]);
|
||||
});
|
||||
|
||||
it('een kanaal dat nog sluit (een closing-hash van nullen) heeft geen closing-tx', async () => {
|
||||
KANALEN = { open: [], closed: [{ channel_point: `${B}:0`, closing_tx_hash: '0'.repeat(64) }] };
|
||||
assert.deepEqual([...await channelTxids(9)], [B]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lightningAccountSats', () => {
|
||||
it('telt LND, en een NWC-wallet niet ernaast want die is een deel van de outbound', () => {
|
||||
assert.equal(lightningAccountSats([
|
||||
{ kind: 'lnd', balanceSats: 250_000 }, { kind: 'nwc', balanceSats: 21 }, { kind: 'xpub', balanceSats: null }]), 250_000);
|
||||
});
|
||||
|
||||
it('telt een NWC-wallet wel op een account zonder LND, en een nooit bijgewerkte bron als nul', () => {
|
||||
assert.equal(lightningAccountSats([{ kind: 'nwc', balanceSats: 21 }, { kind: 'nwc', balanceSats: null }]), 21);
|
||||
});
|
||||
|
||||
it('herkent alleen LND en NWC als lightning', () => {
|
||||
assert.deepEqual(['lnd', 'nwc', 'xpub', 'address'].map(kind => isLightningSource({ kind })), [true, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countMovements', () => {
|
||||
const gekoppeld = new Set([1, 4]);
|
||||
const isLinked = tx => gekoppeld.has(tx.id);
|
||||
|
||||
it('een betaling op twee bronnen van één account is één beweging, gekoppeld als één rij dat is', () => {
|
||||
const r = countMovements([
|
||||
{ id: 1, layer: 'lightning', accountId: 2, txid: 'zap' },
|
||||
{ id: 2, layer: 'lightning', accountId: 2, txid: 'zap' },
|
||||
{ id: 3, layer: 'lightning', accountId: 2, txid: 'factuur' },
|
||||
], isLinked);
|
||||
assert.deepEqual(r, { total: 2, linked: 1 });
|
||||
});
|
||||
|
||||
it('dezelfde hash op een ander account, of als on-chain txid, telt apart', () => {
|
||||
const r = countMovements([
|
||||
{ id: 1, layer: 'lightning', accountId: 2, txid: 'zap' },
|
||||
{ id: 5, layer: 'lightning', accountId: 3, txid: 'zap' },
|
||||
{ id: 6, layer: 'onchain', accountId: 2, txid: 'zap' },
|
||||
], isLinked);
|
||||
assert.deepEqual(r, { total: 3, linked: 1 });
|
||||
});
|
||||
|
||||
it('een on-chain rij in twee walletlijsten telt één keer; twee on-chain rijen blijven twee', () => {
|
||||
const r = countMovements([
|
||||
{ id: 4, layer: 'onchain', accountId: 2, txid: 'a' },
|
||||
{ id: 4, layer: 'onchain', accountId: 2, txid: 'a' },
|
||||
{ id: 7, layer: 'onchain', accountId: 3, txid: 'a' },
|
||||
], isLinked);
|
||||
assert.deepEqual(r, { total: 2, linked: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Generated, do not edit. sha256:9871fb04293a9538a99372a76f6c50a51c7cfcd7eb2971b99eaf20efe539553d
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
|
||||
let fetchCalls = [];
|
||||
globalThis.fetch = async (url, opties) => {
|
||||
const body = JSON.parse(opties.body);
|
||||
fetchCalls.push(body.provider.key);
|
||||
const antwoord = body.date ? { time: Math.floor(Date.parse(`${body.date}T00:00:00Z`) / 1000), price: 60 } : { price: 60 };
|
||||
return { ok: true, status: 200, json: async () => antwoord };
|
||||
};
|
||||
|
||||
const { run, lastId, one } = harness.exports;
|
||||
let goud;
|
||||
const maand = new Date().toISOString().slice(0, 7);
|
||||
|
||||
function provider(key, { limiet = null, gebruikt = 0, rang = 1, caps = ['spot', 'history_point'] } = {}) {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`, [key, key, key]);
|
||||
const id = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [id]);
|
||||
for (const c of caps) {
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, ?, 'GET', '/p')`, [id, c]);
|
||||
run(`INSERT OR IGNORE INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, ?, ?, ?, 'confirmed')`, [goud, c === 'spot' ? 'spot' : 'history', id, rang]);
|
||||
}
|
||||
if (limiet) run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?, ?, '2026-09-01T00:00:00Z')`, [id, limiet]);
|
||||
if (gebruikt) {
|
||||
run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs)
|
||||
VALUES (?, 'spot', ?, ?, 0, 0, 0)`, [id, `${maand}-01T00:01`, gebruikt]);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [goud]);
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.clearSpotCache();
|
||||
facade.forgetSeriesFetches();
|
||||
facade.forgetUsage();
|
||||
fetchCalls = [];
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`);
|
||||
goud = lastId();
|
||||
});
|
||||
|
||||
describe('de maandlimiet', () => {
|
||||
it('stuurt geen verzoek meer naar een bron op zijn limiet', async () => {
|
||||
provider('goldapi', { limiet: 100, gebruikt: 100 });
|
||||
assert.equal(await facade.liveSpotFor(asset()), null);
|
||||
assert.deepEqual(fetchCalls, [], 'niets naar buiten');
|
||||
});
|
||||
|
||||
it('telt de verzoeken van deze sessie mee, en weigert het eerste boven de limiet', async () => {
|
||||
provider('goldapi', { limiet: 3, gebruikt: 1 });
|
||||
for (let i = 0; i < 3; i++) { facade.clearSpotCache(); await facade.liveSpotFor(asset()); }
|
||||
assert.equal(fetchCalls.length, 2, 'één stond er al, dus nog twee mogen');
|
||||
});
|
||||
|
||||
it('schakelt voor de spot over op de volgende rang', async () => {
|
||||
provider('goldapi', { limiet: 100, gebruikt: 100, rang: 1 });
|
||||
provider('reserve', { rang: 2 });
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 60 });
|
||||
assert.deepEqual(fetchCalls, ['reserve']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillClosesFor met een limiet', () => {
|
||||
it('gaat na de limiet van rang 1 verder bij rang 2', async () => {
|
||||
provider('goldapi', { limiet: 1, rang: 1 });
|
||||
provider('reserve', { rang: 2 });
|
||||
const uit = await facade.fillClosesFor(asset(), '2026-09-01', '2026-09-03', { today: '2026-09-24' });
|
||||
assert.deepEqual(fetchCalls, ['goldapi', 'reserve', 'reserve']);
|
||||
assert.equal(uit.written, 3);
|
||||
assert.equal(uit.limitReached, false);
|
||||
});
|
||||
|
||||
it('stopt de ronde als geen enkele bron nog tegoed heeft, in plaats van alle dagen af te lopen', async () => {
|
||||
provider('goldapi', { limiet: 1 });
|
||||
const uit = await facade.fillClosesFor(asset(), '2026-01-01', '2026-09-01', { today: '2026-09-24' });
|
||||
assert.deepEqual(fetchCalls, ['goldapi']);
|
||||
assert.equal(uit.limitReached, true);
|
||||
assert.ok(uit.requested <= 2, `hij liep ${uit.requested} dagen af`);
|
||||
});
|
||||
|
||||
it('stopt tussen twee dagen als het signaal is afgebroken', async () => {
|
||||
provider('goldapi');
|
||||
const stop = new AbortController();
|
||||
stop.abort();
|
||||
const uit = await facade.fillClosesFor(asset(), '2026-09-01', '2026-09-10', { today: '2026-09-24', signal: stop.signal });
|
||||
assert.deepEqual(fetchCalls, []);
|
||||
assert.equal(uit.stopped, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Generated, do not edit. sha256:e4e79ee7e120dce5fb4b9cab93e8098467cf35f61e34bbbef06650b85960c70d
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { testLnd } = await import('../../client/lightning/lnd.js');
|
||||
|
||||
function metAntwoord(json, fn) {
|
||||
const origineel = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify(json), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
return Promise.resolve(fn()).finally(() => { globalThis.fetch = origineel; });
|
||||
}
|
||||
|
||||
describe('testLnd', () => {
|
||||
it('leest alias, synchronisatie en blokhoogte uit het antwoord van LND', async () => {
|
||||
await metAntwoord({ alias: 'Truman', synced_to_chain: true, block_height: 921334 }, async () => {
|
||||
assert.deepEqual(await testLnd(3), { alias: 'Truman', synced: true, blockHeight: 921334 });
|
||||
});
|
||||
});
|
||||
|
||||
it('zonder blokhoogte in het antwoord: 0 en geen fout', async () => {
|
||||
await metAntwoord({ alias: '', synced_to_chain: false }, async () => {
|
||||
assert.deepEqual(await testLnd(3), { alias: null, synced: false, blockHeight: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// Generated, do not edit. sha256:039e04e27156f848885588b6e6606b98400d17636d393358e29be3da504be7d4
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const OPGESLAGEN = new Map();
|
||||
let SCHRIJVEN = 0;
|
||||
mock.module('../../client/data/preferences.js', {
|
||||
namedExports: {
|
||||
getPref: async (scope, key, d) => (OPGESLAGEN.has(`${scope}|${key}`) ? structuredClone(OPGESLAGEN.get(`${scope}|${key}`)) : d),
|
||||
setPref: async (scope, key, v) => { SCHRIJVEN++; OPGESLAGEN.set(`${scope}|${key}`, structuredClone(v)); },
|
||||
},
|
||||
});
|
||||
|
||||
const { MOMENTUM, addPrice, restore, tally, loadMomentum, recordSpot, momentumOf, clearMomentum } =
|
||||
await import('../../client/price/momentum.js');
|
||||
|
||||
const MIN = 60000;
|
||||
|
||||
function reeks(prijzen, start = 0) {
|
||||
return prijzen.reduce((b, p, i) => addPrice(b, p, start + i * MIN), null);
|
||||
}
|
||||
|
||||
describe('addPrice en tally', () => {
|
||||
it('telt een stijging en een daling als twee bewegingen', () => {
|
||||
const t = tally(reeks([100, 101, 100]), 3 * MIN);
|
||||
assert.deepEqual([t.up, t.down], [1, 1]);
|
||||
});
|
||||
|
||||
it('een ongewijzigde prijs is geen beweging', () => {
|
||||
const t = tally(reeks([100, 100, 100, 101, 101]), 5 * MIN);
|
||||
assert.deepEqual([t.up, t.down], [1, 0]);
|
||||
});
|
||||
|
||||
it('houdt alleen de laatste SIZE bewegingen', () => {
|
||||
const prijzen = [];
|
||||
for (let i = 0; i <= MOMENTUM.SIZE; i++) prijzen.push(1000 - i);
|
||||
for (let i = 1; i <= 10; i++) prijzen.push(1000 - MOMENTUM.SIZE + i);
|
||||
const t = tally(reeks(prijzen), prijzen.length * MIN);
|
||||
assert.equal(t.up + t.down, MOMENTUM.SIZE);
|
||||
assert.deepEqual([t.up, t.down], [10, MOMENTUM.SIZE - 10]);
|
||||
});
|
||||
|
||||
it('een sprong na een oude referentie is geen beweging', () => {
|
||||
let b = addPrice(null, 100, 0);
|
||||
b = addPrice(b, 200, MOMENTUM.MAX_AGE_MS + MIN);
|
||||
assert.deepEqual(b.moves, [], 'de nacht ertussen telt niet');
|
||||
assert.equal(b.last, 200, 'maar de nieuwe prijs is wel de referentie');
|
||||
});
|
||||
|
||||
it('oude bewegingen vallen uit de telling', () => {
|
||||
const b = reeks([100, 101, 102]);
|
||||
assert.equal(tally(b, 2 * MIN).up, 2);
|
||||
assert.equal(tally(b, 2 * MIN + MOMENTUM.MAX_AGE_MS + 1).up, 0);
|
||||
});
|
||||
|
||||
it('een ongeldige prijs laat de buffer staan', () => {
|
||||
const b = reeks([100, 101]);
|
||||
assert.equal(addPrice(b, null, 3 * MIN), b);
|
||||
assert.equal(addPrice(b, 0, 3 * MIN), b);
|
||||
assert.equal(addPrice(b, NaN, 3 * MIN), b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('de richting', () => {
|
||||
it('zonder beweging geen richting', () => {
|
||||
assert.equal(tally(reeks([100]), 0).tone, null);
|
||||
assert.equal(tally(reeks([100, 100]), 0).tone, null);
|
||||
});
|
||||
|
||||
it('één beweging is al een richting', () => {
|
||||
assert.equal(tally(reeks([100, 101]), 0).tone, 'up');
|
||||
assert.equal(tally(reeks([100, 99]), 0).tone, 'down');
|
||||
});
|
||||
|
||||
it('de meerderheid wint, ook als de laatste beweging de andere kant op gaat', () => {
|
||||
assert.equal(tally(reeks([100, 101, 102, 50]), 0).tone, 'up');
|
||||
});
|
||||
|
||||
it('gelijke stand houdt wat er stond: eerst omhoog dan omlaag blijft groen', () => {
|
||||
const t = tally(reeks([100, 101, 100]), 0);
|
||||
assert.equal(t.up, t.down);
|
||||
assert.equal(t.tone, 'up');
|
||||
});
|
||||
|
||||
it('en andersom blijft rood', () => {
|
||||
assert.equal(tally(reeks([100, 99, 100]), 0).tone, 'down');
|
||||
});
|
||||
|
||||
it('gelijke stand na een omslag houdt de laatste richting, niet de eerste', () => {
|
||||
assert.equal(tally(reeks([100, 101, 102, 101, 100, 99, 100]), 0).tone, 'down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore: een opgeslagen buffer terug in het geheugen', () => {
|
||||
it('laat verlopen bewegingen weg en houdt de rest', () => {
|
||||
const nu = MOMENTUM.MAX_AGE_MS + 10 * MIN;
|
||||
const b = restore({ last: 100, lastAt: nu - MIN, moves: [{ at: 0, dir: 1 }, { at: nu - 2 * MIN, dir: -1 }] }, nu);
|
||||
assert.deepEqual(b.moves, [{ at: nu - 2 * MIN, dir: -1 }]);
|
||||
assert.equal(b.lastAt, nu - MIN, 'de leeftijd van de referentie gaat mee');
|
||||
});
|
||||
|
||||
it('weigert wat geen buffer is', () => {
|
||||
for (const fout of [null, 'x', {}, { last: 100, lastAt: 0 }, { last: 'a', lastAt: 0, moves: [] }]) {
|
||||
assert.equal(restore(fout, 0), null, JSON.stringify(fout));
|
||||
}
|
||||
});
|
||||
|
||||
it('laat een kapotte beweging weg in plaats van de hele buffer', () => {
|
||||
const b = restore({ last: 100, lastAt: 0, moves: [{ at: 0, dir: 1 }, { at: 0, dir: 7 }, null] }, 0);
|
||||
assert.deepEqual(b.moves, [{ at: 0, dir: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('de opslag per asset', () => {
|
||||
beforeEach(() => { clearMomentum(); OPGESLAGEN.clear(); SCHRIJVEN = 0; });
|
||||
|
||||
it('houdt twee assets apart', async () => {
|
||||
await recordSpot(1, 100, 0);
|
||||
await recordSpot(1, 101, MIN);
|
||||
await recordSpot(2, 100, 0);
|
||||
await recordSpot(2, 99, MIN);
|
||||
assert.deepEqual([momentumOf(1, MIN).up, momentumOf(1, MIN).down], [1, 0]);
|
||||
assert.deepEqual([momentumOf(2, MIN).up, momentumOf(2, MIN).down], [0, 1]);
|
||||
});
|
||||
|
||||
it('een asset zonder bewegingen heeft geen richting', () => {
|
||||
assert.deepEqual(momentumOf(9), { up: 0, down: 0, tone: null });
|
||||
});
|
||||
|
||||
it('schrijft de buffer weg, en niet bij een ongewijzigde prijs', async () => {
|
||||
await recordSpot(1, 100, 0);
|
||||
await recordSpot(1, 101, MIN);
|
||||
assert.equal(SCHRIJVEN, 2);
|
||||
await recordSpot(1, 101, 2 * MIN);
|
||||
assert.equal(SCHRIJVEN, 2, 'dezelfde prijs is geen schrijfactie');
|
||||
assert.equal(OPGESLAGEN.get('momentum|1').moves.length, 1);
|
||||
});
|
||||
|
||||
it('een nieuwe sessie gaat verder met de opgeslagen buffer', async () => {
|
||||
await recordSpot(1, 100, 0);
|
||||
await recordSpot(1, 101, MIN);
|
||||
clearMomentum();
|
||||
assert.equal(momentumOf(1, MIN).up, 0, 'nog niet geladen');
|
||||
await loadMomentum(1, 2 * MIN);
|
||||
assert.equal(momentumOf(1, 2 * MIN).up, 1, 'geladen');
|
||||
await recordSpot(1, 102, 2 * MIN);
|
||||
assert.equal(momentumOf(1, 2 * MIN).up, 2, 'en een nieuwe prijs telt erbij in plaats van eroverheen');
|
||||
});
|
||||
|
||||
it('een buffer van gisteren telt niet mee, en zijn referentie ook niet', async () => {
|
||||
const gisteren = 0;
|
||||
const vandaag = MOMENTUM.MAX_AGE_MS + 60 * MIN;
|
||||
const prijzen = Array.from({ length: 9 }, (_, i) => 100 + i);
|
||||
for (let i = 0; i < prijzen.length; i++) await recordSpot(1, prijzen[i], gisteren + i * MIN);
|
||||
clearMomentum();
|
||||
await loadMomentum(1, vandaag);
|
||||
assert.deepEqual(momentumOf(1, vandaag), { up: 0, down: 0, tone: null });
|
||||
await recordSpot(1, 50, vandaag);
|
||||
assert.equal(momentumOf(1, vandaag).down, 0, 'de val sinds gisteravond is geen beweging');
|
||||
});
|
||||
|
||||
it('een onleesbare opgeslagen waarde begint gewoon leeg', async () => {
|
||||
OPGESLAGEN.set('momentum|3', 'geen buffer');
|
||||
await recordSpot(3, 100, 0);
|
||||
await recordSpot(3, 101, MIN);
|
||||
assert.equal(momentumOf(3, MIN).up, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Generated, do not edit. sha256:c62ff88846496db91f21b929a9f3c90f1478ad07262b244968b0d786ea63f4b8
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
import { rekenvalutaUitDatabase } from '../helpers/rekenvaluta.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'assets']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
await rekenvalutaUitDatabase(mock);
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
|
||||
const tijd = dag => Math.floor(Date.parse(`${dag}T00:00:00Z`) / 1000);
|
||||
let antwoord;
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => antwoord });
|
||||
|
||||
const { run, lastId, one } = harness.exports;
|
||||
let goud, usd;
|
||||
|
||||
function asset(symbol, kind) {
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES (?,?,?,?,?,'cent',2,'unit')`, [symbol, symbol, symbol.toLowerCase(), kind, symbol]);
|
||||
return lastId();
|
||||
}
|
||||
function dollarbron() {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('dollar','dollar','dollar','https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`);
|
||||
const p = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [p]);
|
||||
for (const c of ['spot', 'history_point', 'daily_history']) {
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, quoteCurrency)
|
||||
VALUES (?, ?, 'GET', '/p', 'USD')`, [p, c]);
|
||||
}
|
||||
for (const keuze of ['spot', 'history']) {
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, ?, ?, 1, 'confirmed')`, [goud, keuze, p]);
|
||||
}
|
||||
}
|
||||
function koers(dag, eur) {
|
||||
run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt)
|
||||
VALUES (?, ?, 'day', ?, (SELECT id FROM Provider WHERE key = 'manual'), '2026-09-24T00:00:00Z')`,
|
||||
[usd, `${dag}T00:00:00Z`, eur]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.clearSpotCache();
|
||||
facade.forgetSeriesFetches();
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('manual','manual','manual','','none',0,1,1,1,'2026-09-01T10:00:00.000Z')`);
|
||||
goud = asset('XAU', 'metal');
|
||||
usd = asset('USD', 'fiat');
|
||||
dollarbron();
|
||||
});
|
||||
const rij = id => one(`SELECT * FROM Asset WHERE id = ?`, [id]);
|
||||
|
||||
describe('omrekenen naar euro', () => {
|
||||
it('rekent een punt om met de koers van die dag, en laat het ruwe bedrag niet achter', async () => {
|
||||
koers('2026-09-18', 0.85);
|
||||
antwoord = { time: tijd('2026-09-18'), price: 2000 };
|
||||
const p = await facade.historicalPointForVia(rij(goud), '2026-09-18');
|
||||
assert.deepEqual(p, { time: tijd('2026-09-18'), price: 1700 });
|
||||
});
|
||||
|
||||
it('neemt in het weekend de koers van vrijdag', async () => {
|
||||
koers('2026-09-18', 0.85);
|
||||
antwoord = { time: tijd('2026-09-20'), price: 2000 };
|
||||
assert.equal((await facade.historicalPointForVia(rij(goud), '2026-09-20')).price, 1700);
|
||||
});
|
||||
|
||||
it('geeft GEEN prijs als de koers ouder is dan de grens, en zeker geen dollars als euro', async () => {
|
||||
koers('2026-09-01', 0.85);
|
||||
antwoord = { time: tijd('2026-09-18'), price: 2000 };
|
||||
assert.equal(await facade.historicalPointForVia(rij(goud), '2026-09-18'), null);
|
||||
});
|
||||
|
||||
it('geeft geen prijs zonder asset voor die munt', async () => {
|
||||
run(`DELETE FROM Asset WHERE id = ?`, [usd]);
|
||||
antwoord = { time: tijd('2026-09-18'), price: 2000 };
|
||||
assert.equal(await facade.historicalPointForVia(rij(goud), '2026-09-18'), null);
|
||||
});
|
||||
|
||||
it('laat in een reeks de dagen zonder koers vallen en rekent de rest om', async () => {
|
||||
koers('2026-09-17', 0.8);
|
||||
antwoord = [
|
||||
{ time: tijd('2026-08-01'), price: 1900 },
|
||||
{ time: tijd('2026-09-17'), price: 2000 },
|
||||
{ time: tijd('2026-09-18'), price: 2100 },
|
||||
];
|
||||
const reeks = await facade.fetchPriceVia('dollar', rij(goud), 'daily_history');
|
||||
assert.deepEqual(reeks, [
|
||||
{ time: tijd('2026-09-17'), price: 1600 },
|
||||
{ time: tijd('2026-09-18'), price: 1680 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rekent een spot om met de nieuwste koers', async () => {
|
||||
const gisteren = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
koers(gisteren, 0.9);
|
||||
antwoord = { price: 2000 };
|
||||
assert.deepEqual(await facade.liveSpotFor(rij(goud)), { price: 1800 });
|
||||
});
|
||||
|
||||
it('rateSourceFor: geen bron zonder keten, wel met een actieve bron, niet met een uitgezette', async () => {
|
||||
assert.equal(await facade.rateSourceFor('USD'), null, 'geen keten');
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('ecb','ECB','ecb','https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`);
|
||||
const ecb = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'USD')`, [ecb]);
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, 'daily_history', 'GET', '/r')`, [ecb]);
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, 'history', ?, 1, 'confirmed')`, [usd, ecb]);
|
||||
assert.equal((await facade.rateSourceFor('USD')).key, 'ecb');
|
||||
run(`UPDATE Provider SET isActive = 0 WHERE id = ?`, [ecb]);
|
||||
assert.equal(await facade.rateSourceFor('USD'), null, 'uitgezet');
|
||||
assert.equal(await facade.rateSourceFor('EUR'), null, 'de rekenvaluta wordt niet omgerekend');
|
||||
});
|
||||
|
||||
it('currentRate: de koers van vandaag uit de reeks, 1 voor de rekenvaluta, en niets zonder koers', async () => {
|
||||
const dag = n => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
|
||||
assert.equal(await facade.currentRate('EUR'), 1);
|
||||
assert.equal(await facade.currentRate('USD'), null, 'nog geen koers');
|
||||
koers(dag(30), 0.8);
|
||||
assert.equal(await facade.currentRate('USD'), null, 'een koers van een maand geleden is geen koers van vandaag');
|
||||
koers(dag(1), 0.86);
|
||||
assert.equal(await facade.currentRate('USD'), 0.86);
|
||||
assert.equal(await facade.currentRate('GBP'), null, 'geen asset voor die munt');
|
||||
});
|
||||
|
||||
it('rekent een andere munt dan de dollar om met de koers van dát fiat-asset', async () => {
|
||||
run(`UPDATE ProviderEndpoint SET quoteCurrency = 'GBP'`);
|
||||
const gbp = asset('GBP', 'fiat');
|
||||
run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt)
|
||||
VALUES (?, '2026-09-18T00:00:00Z', 'day', 1.2, (SELECT id FROM Provider WHERE key = 'manual'), '2026-09-24T00:00:00Z')`, [gbp]);
|
||||
antwoord = { time: tijd('2026-09-18'), price: 1000 };
|
||||
assert.deepEqual(await facade.historicalPointForVia(rij(goud), '2026-09-18'),
|
||||
{ time: tijd('2026-09-18'), price: 1200 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Generated, do not edit. sha256:e3f07d60b1d7716de10f35f932ae6f2f2e3db5a07433459721f39ceee41a1a6d
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
|
||||
const gisteren = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
const tijd = dag => Math.floor(Date.parse(`${dag}T00:00:00Z`) / 1000);
|
||||
let fetchCalls = 0;
|
||||
let faalt = false;
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalls++;
|
||||
if (faalt) throw new Error('geen netwerk in deze test');
|
||||
return { ok: true, status: 200, json: async () => [{ time: tijd(gisteren), price: 0.87 }] };
|
||||
};
|
||||
|
||||
const { run, lastId, one } = harness.exports;
|
||||
let usd;
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.forgetSeriesFetches();
|
||||
fetchCalls = 0;
|
||||
faalt = false;
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('USD','Dollar','dollar','fiat','USD','cent',2,'unit')`);
|
||||
usd = lastId();
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('ecb','ECB','ecb','https://voorbeeld.test','none',0,1,1,0,'2026-09-01T10:00:00.000Z')`);
|
||||
const p = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'USD')`, [p]);
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate) VALUES (?, 'daily_history', 'GET', '/reeks')`, [p]);
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, 'history', ?, 1, 'confirmed')`, [usd, p]);
|
||||
});
|
||||
const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [usd]);
|
||||
|
||||
describe('een reeks die vandaag niet haalt', () => {
|
||||
it('gaat op één dag maar één keer naar buiten', async () => {
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(fetchCalls, 1);
|
||||
assert.equal(one(`SELECT fiatPrice FROM PriceHistory WHERE assetId = ?`, [usd]).fiatPrice, 0.87, 'en de reeks staat er');
|
||||
});
|
||||
|
||||
it('probeert het na een MISLUKTE poging gewoon opnieuw', async () => {
|
||||
faalt = true;
|
||||
await assert.rejects(() => facade.ensurePriceHistoryFor(asset()));
|
||||
faalt = false;
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(fetchCalls, 2);
|
||||
});
|
||||
|
||||
it('haalt na een bronwijziging wel opnieuw op', async () => {
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
facade.forgetSeriesFetches();
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.equal(fetchCalls, 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Generated, do not edit. sha256:5fcf7092848d33c8b3e377e39e0ea6329b71d804e01a41ef65f10de8dcfb1874
|
||||
import { describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const TAP = 'bc1p-taproot-input';
|
||||
const SEG = 'bc1q-segwit-wisselgeld';
|
||||
const KANAAL = 'bc1q-kanaal-multisig';
|
||||
|
||||
const FUNDING = {
|
||||
txid: 'f'.repeat(64), fee: 1000,
|
||||
status: { confirmed: true, block_height: 900_000, block_time: 1_760_000_000 },
|
||||
vin: [{ prevout: { scriptpubkey_address: TAP, value: 100_000 } }],
|
||||
vout: [{ scriptpubkey_address: KANAAL, value: 70_000 }, { scriptpubkey_address: SEG, value: 29_000 }],
|
||||
};
|
||||
mock.module('../../client/price/btc-explorer.js', { namedExports: {
|
||||
addressTxs: async () => [FUNDING],
|
||||
fetchJson: async () => ({}),
|
||||
explorerBase: async () => 'https://explorer.test',
|
||||
} });
|
||||
|
||||
const { scanMovements } = await import('../../client/price/wallet-scanner.js');
|
||||
|
||||
describe('scanMovements over twee bronnen van één account', () => {
|
||||
it('de SegWit-bron ziet met de Taproot-adressen erbij de kanaalopening als uitgaand', async () => {
|
||||
const { rows } = await scanMovements([SEG], 1, 2, null, { ownAddresses: [TAP, SEG] });
|
||||
assert.deepEqual(rows.map(r => [r.direction, r.valueSats, r.feeSats]), [['out', 70_000, 1000]]);
|
||||
});
|
||||
|
||||
it('de Taproot-bron komt op precies dezelfde beweging uit', async () => {
|
||||
const { rows } = await scanMovements([TAP], 1, 3, null, { ownAddresses: [TAP, SEG] });
|
||||
assert.deepEqual(rows.map(r => [r.direction, r.valueSats, r.feeSats]), [['out', 70_000, 1000]]);
|
||||
});
|
||||
|
||||
it('zonder de adressen van het account ziet de SegWit-bron alleen het wisselgeld: de oude fout', async () => {
|
||||
const { rows } = await scanMovements([SEG], 1, 2, null);
|
||||
assert.deepEqual(rows.map(r => [r.direction, r.valueSats]), [['in', 29_000]]);
|
||||
});
|
||||
|
||||
it('ook andersom: een SegWit-input met het wisselgeld naar Taproot', async () => {
|
||||
FUNDING.vin = [{ prevout: { scriptpubkey_address: SEG, value: 100_000 } }];
|
||||
FUNDING.vout = [{ scriptpubkey_address: KANAAL, value: 70_000 }, { scriptpubkey_address: TAP, value: 29_000 }];
|
||||
try {
|
||||
for (const [bron, eigen] of [[[SEG], 2], [[TAP], 3]]) {
|
||||
const { rows } = await scanMovements(bron, 1, eigen, null, { ownAddresses: [TAP, SEG] });
|
||||
assert.deepEqual(rows.map(r => [r.direction, r.valueSats, r.feeSats]), [['out', 70_000, 1000]]);
|
||||
}
|
||||
const { rows: oud } = await scanMovements([TAP], 1, 3, null);
|
||||
assert.deepEqual(oud.map(r => [r.direction, r.valueSats]), [['in', 29_000]], 'per bron genet: alleen het wisselgeld');
|
||||
} finally {
|
||||
FUNDING.vin = [{ prevout: { scriptpubkey_address: TAP, value: 100_000 } }];
|
||||
FUNDING.vout = [{ scriptpubkey_address: KANAAL, value: 70_000 }, { scriptpubkey_address: SEG, value: 29_000 }];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Generated, do not edit. sha256:17aa65162f57d2f6155970591db45673bb76363e851935e6d5a324bd4bab456f
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
import { CONFIG } from '../../shared/config.js';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
|
||||
const VANDAAG = '2026-09-24';
|
||||
|
||||
let fetchCalls = [];
|
||||
let faalt = false;
|
||||
let reeksTot = null;
|
||||
let fouten = new Map();
|
||||
const geenKoers = { key: 'integrations.price.noValidPrice', params: { provider: 'perdatum' } };
|
||||
const http = status => ({ key: 'integrations.price.httpFailed', params: { provider: 'perdatum', capability: 'history_point', status, detail: '' } });
|
||||
function stub(url, opties) {
|
||||
const body = JSON.parse(opties.body);
|
||||
fetchCalls.push(body);
|
||||
if (faalt) throw new Error('geen netwerk in deze test');
|
||||
if (fouten.has(body.date)) {
|
||||
const f = fouten.get(body.date);
|
||||
return { ok: false, status: 502, json: async () => ({ error: 'geen koers', ...f }) };
|
||||
}
|
||||
if (body.capability === 'daily_history') {
|
||||
const rijen = ['2026-09-18', '2026-09-19', '2026-09-20'].filter(d => d <= reeksTot)
|
||||
.map(d => ({ time: Math.floor(Date.parse(`${d}T00:00:00Z`) / 1000), price: 80 }));
|
||||
return { ok: true, status: 200, json: async () => rijen };
|
||||
}
|
||||
const time = Math.floor(Date.parse(`${body.date}T00:00:00Z`) / 1000);
|
||||
return { ok: true, status: 200, json: async () => ({ time, price: 90 }) };
|
||||
}
|
||||
globalThis.fetch = async (url, opties) => stub(url, opties);
|
||||
|
||||
const { run, all, one, lastId } = harness.exports;
|
||||
let goud, perDatum;
|
||||
|
||||
function provider(key, capabilities, { isSystem = 0 } = {}) {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,?,'2026-09-01T10:00:00.000Z')`, [key, key, key, isSystem]);
|
||||
const id = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'XAU')`, [id]);
|
||||
for (const c of capabilities) {
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate)
|
||||
VALUES (?, ?, 'GET', '/prijs', NULL)`, [id, c]);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
function bronkeuze(providerId, capability) {
|
||||
const keuze = capability === 'spot' ? 'spot' : 'history';
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, ?, ?, (SELECT COALESCE(MAX(rank), 0) + 1 FROM PriceSource WHERE assetId = ? AND capability = ?), 'confirmed')`,
|
||||
[goud, keuze, providerId, goud, keuze]);
|
||||
}
|
||||
function dagrij(providerId, dag, eur, updatedAt) {
|
||||
run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, updatedAt)
|
||||
VALUES (?, ?, 'day', ?, ?, ?)`, [goud, `${dag}T00:00:00Z`, eur, providerId, updatedAt]);
|
||||
}
|
||||
const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [goud]);
|
||||
const gevraagd = () => fetchCalls.map(c => c.date);
|
||||
const koersOp = dag => one(`SELECT fiatPrice FROM PriceHistory WHERE assetId = ? AND observedAt = ? AND providerId = ?`,
|
||||
[goud, `${dag}T00:00:00Z`, perDatum])?.fiatPrice;
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.forgetSeriesFetches();
|
||||
fetchCalls = [];
|
||||
faalt = false;
|
||||
reeksTot = null;
|
||||
fouten = new Map();
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`);
|
||||
goud = lastId();
|
||||
perDatum = provider('perdatum', ['spot', 'history_point']);
|
||||
bronkeuze(perDatum, 'history_point');
|
||||
});
|
||||
|
||||
describe('fillClosesFor', () => {
|
||||
it('vervangt een rij die OP zijn eigen dag geschreven is, en laat een slotkoers staan', async () => {
|
||||
dagrij(perDatum, '2026-09-21', 70, '2026-09-21T12:00:00.000Z');
|
||||
dagrij(perDatum, '2026-09-22', 80, '2026-09-23T08:00:00.000Z');
|
||||
const uit = await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG });
|
||||
assert.deepEqual(gevraagd(), ['2026-09-21', '2026-09-23'], 'de slotkoers van de 22e wordt niet opnieuw gevraagd');
|
||||
assert.deepEqual(uit, { requested: 2, written: 2, limitReached: false, stopped: false });
|
||||
assert.equal(koersOp('2026-09-21'), 90, 'de middagkoers is overschreven');
|
||||
assert.equal(koersOp('2026-09-22'), 80, 'en de slotkoers staat er nog');
|
||||
});
|
||||
|
||||
it('vraagt vandaag nooit, ook niet als het bereik tot vandaag loopt', async () => {
|
||||
await facade.fillClosesFor(asset(), '2026-09-23', VANDAAG, { today: VANDAAG });
|
||||
assert.deepEqual(gevraagd(), ['2026-09-23']);
|
||||
});
|
||||
|
||||
it('laat een koers op manual staan, ook als hij op dezelfde dag is ingevoerd', async () => {
|
||||
const manual = provider('manual', [], { isSystem: 1 });
|
||||
dagrij(manual, '2026-09-22', 315.5, '2026-09-22T09:00:00.000Z');
|
||||
await facade.fillClosesFor(asset(), '2026-09-22', '2026-09-22', { today: VANDAAG });
|
||||
assert.deepEqual(gevraagd(), [], 'een met de hand ingevoerde koers is een bewuste keuze');
|
||||
});
|
||||
|
||||
it('haalt bij een bron met een reeks eerst de reeks, en vraagt alleen de dagen die hij mist', async () => {
|
||||
bronkeuze(provider('reeks', ['daily_history']), 'daily_history');
|
||||
reeksTot = '2026-09-20';
|
||||
const uit = await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-23', { today: VANDAAG });
|
||||
assert.deepEqual(fetchCalls.map(c => c.capability), ['daily_history', 'history_point', 'history_point', 'history_point']);
|
||||
assert.deepEqual(gevraagd().slice(1), ['2026-09-21', '2026-09-22', '2026-09-23']);
|
||||
assert.deepEqual(uit, { requested: 3, written: 3, limitReached: false, stopped: false });
|
||||
});
|
||||
|
||||
it('vraagt niets per datum als de reeks het hele bereik dekt', async () => {
|
||||
bronkeuze(provider('reeks', ['daily_history']), 'daily_history');
|
||||
reeksTot = '2026-09-20';
|
||||
await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-20', { today: VANDAAG });
|
||||
assert.deepEqual(fetchCalls.map(c => c.capability), ['daily_history']);
|
||||
});
|
||||
|
||||
it('vraagt bij metaal geen zaterdag en zondag', async () => {
|
||||
const uit = await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-21', { today: VANDAAG });
|
||||
assert.deepEqual(gevraagd(), ['2026-09-18', '2026-09-21'], 'vrijdag en maandag, het weekend ertussen niet');
|
||||
assert.equal(uit.requested, 2);
|
||||
assert.equal(facade.daysToAsk(asset(), '2026-09-18', '2026-09-21'), 2);
|
||||
});
|
||||
|
||||
it('vraagt bij crypto het weekend wél, want die markt is dan open', async () => {
|
||||
run(`UPDATE Asset SET kind = 'crypto' WHERE id = ?`, [goud]);
|
||||
await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-21', { today: VANDAAG });
|
||||
assert.deepEqual(gevraagd(), ['2026-09-18', '2026-09-19', '2026-09-20', '2026-09-21']);
|
||||
assert.equal(facade.daysToAsk(asset(), '2026-09-18', '2026-09-21'), 4);
|
||||
});
|
||||
|
||||
it('slikt een mislukte dag en laat de oude rij staan', async () => {
|
||||
dagrij(perDatum, '2026-09-22', 70, '2026-09-22T12:00:00.000Z');
|
||||
faalt = true;
|
||||
const uit = await facade.fillClosesFor(asset(), '2026-09-22', '2026-09-23', { today: VANDAAG });
|
||||
assert.deepEqual(uit, { requested: 2, written: 0, limitReached: false, stopped: false });
|
||||
assert.equal(koersOp('2026-09-22'), 70, 'een netwerkfout wist niets');
|
||||
assert.deepEqual(onthouden(), [], 'en een netwerkfout is geen dag zonder koers');
|
||||
});
|
||||
});
|
||||
|
||||
const onthouden = () => all(`SELECT day FROM PriceAbsence ORDER BY day`).map(r => r.day);
|
||||
|
||||
describe('fillClosesFor: een dag zonder koers', () => {
|
||||
it('onthoudt de dag en vraagt hem de volgende ronde niet meer', async () => {
|
||||
fouten.set('2026-09-22', geenKoers);
|
||||
const eerste = await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG });
|
||||
assert.equal(eerste.written, 2);
|
||||
assert.deepEqual(onthouden(), ['2026-09-22']);
|
||||
fetchCalls = [];
|
||||
const tweede = await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG });
|
||||
assert.deepEqual(gevraagd(), [], 'de 21e en 23e zijn slot, de 22e is onthouden');
|
||||
assert.equal(tweede.requested, 0);
|
||||
});
|
||||
|
||||
it('telt een 404 van de bron mee, en een 429 of 500 niet', async () => {
|
||||
fouten.set('2026-09-21', http(404));
|
||||
fouten.set('2026-09-22', http(429));
|
||||
fouten.set('2026-09-23', http(500));
|
||||
await facade.fillClosesFor(asset(), '2026-09-18', '2026-09-23', { today: VANDAAG });
|
||||
assert.deepEqual(onthouden(), ['2026-09-21']);
|
||||
});
|
||||
|
||||
it('onthoudt niets van een bron die in die ronde geen enkele koers gaf', async () => {
|
||||
for (const d of ['2026-09-21', '2026-09-22', '2026-09-23']) fouten.set(d, geenKoers);
|
||||
await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG });
|
||||
assert.deepEqual(onthouden(), []);
|
||||
fetchCalls = [];
|
||||
await facade.fillClosesFor(asset(), '2026-09-21', '2026-09-23', { today: VANDAAG });
|
||||
assert.equal(fetchCalls.length, 3, 'de volgende ronde vraagt ze gewoon opnieuw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshRecentCloses', () => {
|
||||
it(`vraagt de werkdagen van de laatste ${CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS} dagen tot en met gisteren, en de tweede ronde niets`, async () => {
|
||||
await facade.refreshRecentCloses([asset()], { today: VANDAAG });
|
||||
const dagen = gevraagd();
|
||||
assert.equal(dagen.length, CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS - 4);
|
||||
assert.equal(dagen[0], '2026-09-10');
|
||||
assert.equal(dagen.at(-1), '2026-09-23', 'gisteren is de laatste dag');
|
||||
|
||||
fetchCalls = [];
|
||||
await facade.refreshRecentCloses([asset()], { today: VANDAAG });
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
assert.equal(all(`SELECT id FROM PriceHistory WHERE assetId = ?`, [goud]).length, CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS - 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Generated, do not edit. sha256:f2615dafd4b52aacda7e6453bb0c703f6c9c0f0f88c1579820810800ed320281
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
const { momentumOf, clearMomentum } = await import('../../client/price/momentum.js');
|
||||
|
||||
let fetchCalls = [];
|
||||
let antwoord = { EUR: 100, USD: null };
|
||||
globalThis.fetch = async (url, opties) => {
|
||||
fetchCalls.push({ url, body: opties?.body });
|
||||
return { ok: true, status: 200, json: async () => antwoord };
|
||||
};
|
||||
|
||||
const { run, lastId, one } = harness.exports;
|
||||
let btc, goud;
|
||||
|
||||
function provider(key, symbolen) {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES (?,?,?, 'https://voorbeeld.test','none',0,1,1,0,'2026-09-21T10:00:00.000Z')`, [key, key, key]);
|
||||
const id = lastId();
|
||||
for (const s of symbolen) run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?,?)`, [id, s]);
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate)
|
||||
VALUES (?, 'spot', 'GET', '/prijs', NULL)`, [id]);
|
||||
return id;
|
||||
}
|
||||
function bronkeuze(providerId, assetId) {
|
||||
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, 'spot', ?, 1, 'confirmed')`, [assetId, providerId]);
|
||||
}
|
||||
const assetRij = id => one(`SELECT * FROM Asset WHERE id = ?`, [id]);
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.clearSpotCache();
|
||||
fetchCalls = [];
|
||||
antwoord = { EUR: 100, USD: null };
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
|
||||
btc = lastId();
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('XAU','Goud','goud','metal','g','mg',3,'unit')`);
|
||||
goud = lastId();
|
||||
});
|
||||
|
||||
describe('de spot wordt gecached', () => {
|
||||
it('doet ÉÉN verzoek voor twee vragen om dezelfde spot', async () => {
|
||||
const p = provider('bron', ['BTC']);
|
||||
bronkeuze(p, btc);
|
||||
const eerste = await facade.liveSpotFor(assetRij(btc));
|
||||
const tweede = await facade.liveSpotFor(assetRij(btc));
|
||||
assert.deepEqual(eerste, { EUR: 100, USD: null });
|
||||
assert.deepEqual(tweede, eerste, 'hetzelfde antwoord');
|
||||
assert.equal(fetchCalls.length, 1, 'en maar één keer naar buiten');
|
||||
});
|
||||
|
||||
it('haalt na leegmaken WEL opnieuw op', async () => {
|
||||
const p = provider('bron', ['BTC']);
|
||||
bronkeuze(p, btc);
|
||||
await facade.liveSpotFor(assetRij(btc));
|
||||
facade.clearSpotCache();
|
||||
antwoord = { EUR: 111, USD: null };
|
||||
assert.deepEqual(await facade.liveSpotFor(assetRij(btc)), { EUR: 111, USD: null },
|
||||
'de nieuwe koers, dus er is echt opnieuw gevraagd');
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
});
|
||||
|
||||
it('houdt twee assets apart, en vraagt één asset maar één keer', async () => {
|
||||
bronkeuze(provider('bron-btc', ['BTC']), btc);
|
||||
bronkeuze(provider('bron-goud', ['XAU']), goud);
|
||||
await facade.liveSpotFor(assetRij(btc));
|
||||
await facade.liveSpotFor(assetRij(goud));
|
||||
assert.equal(fetchCalls.length, 2, 'twee assets zijn twee vragen');
|
||||
await facade.liveSpotFor(assetRij(btc));
|
||||
assert.equal(fetchCalls.length, 2, 'en hetzelfde asset nog eens is geen nieuwe vraag');
|
||||
});
|
||||
|
||||
it('onthoudt welke bron de spot gaf, per asset, en vergeet dat met de cache', async () => {
|
||||
bronkeuze(provider('bitvavo', ['BTC']), btc);
|
||||
assert.equal(facade.lastSpotSource(assetRij(btc)), null);
|
||||
await facade.liveSpotFor(assetRij(btc));
|
||||
assert.equal(facade.lastSpotSource(assetRij(btc)), 'bitvavo');
|
||||
assert.equal(facade.lastSpotSource(assetRij(goud)), null, 'een ander asset vroeg niets');
|
||||
facade.clearSpotCache();
|
||||
assert.equal(facade.lastSpotSource(assetRij(btc)), null);
|
||||
});
|
||||
|
||||
it('voedt de korte trend met elk vers antwoord', async () => {
|
||||
clearMomentum();
|
||||
bronkeuze(provider('bron', ['BTC']), btc);
|
||||
antwoord = { price: 100 };
|
||||
await facade.liveSpotFor(assetRij(btc));
|
||||
facade.clearSpotCache();
|
||||
antwoord = { price: 110 };
|
||||
await facade.liveSpotFor(assetRij(btc));
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepEqual([momentumOf(btc).up, momentumOf(btc).down], [1, 0]);
|
||||
const opgeslagen = JSON.parse(one(`SELECT value FROM Preference WHERE scope = 'momentum' AND key = ?`, [String(btc)]).value);
|
||||
assert.equal(opgeslagen.last, 110);
|
||||
assert.equal(opgeslagen.moves.length, 1);
|
||||
assert.ok(Number.isFinite(opgeslagen.moves[0].at), 'elke beweging draagt zijn tijd');
|
||||
});
|
||||
|
||||
it('bewaart een MISLUKTE poging niet', async () => {
|
||||
const p = provider('bron', ['BTC']);
|
||||
bronkeuze(p, btc);
|
||||
globalThis.fetch = async (url, opties) => {
|
||||
fetchCalls.push({ url, body: opties?.body });
|
||||
throw new Error('geen netwerk in deze test');
|
||||
};
|
||||
assert.equal(await facade.liveSpotFor(assetRij(btc)), null);
|
||||
assert.equal(await facade.liveSpotFor(assetRij(btc)), null);
|
||||
assert.equal(fetchCalls.length, 2, 'twee pogingen, want er is niets bewaard');
|
||||
globalThis.fetch = async (url, opties) => {
|
||||
fetchCalls.push({ url, body: opties?.body });
|
||||
return { ok: true, status: 200, json: async () => antwoord };
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// Generated, do not edit. sha256:762784c61e8bb974ae7fcd7e502db20b12fd6098e6d5292355f3d348d4e202f7
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
for (const mod of ['providers', 'price-source', 'price-history', 'api-metrics', 'preferences']) {
|
||||
mock.module(`../../client/data/${mod}.js`, { namedExports: await import(`../../server/data/${mod}.js`) });
|
||||
}
|
||||
|
||||
const facade = await import('../../client/price/providers.js');
|
||||
|
||||
let fetchCalls = [];
|
||||
globalThis.fetch = async (url, opties) => {
|
||||
fetchCalls.push({ url, body: opties?.body });
|
||||
throw new Error('geen netwerk in deze test');
|
||||
};
|
||||
|
||||
const { run, all, one, lastId } = harness.exports;
|
||||
let eur;
|
||||
|
||||
function vasteKoersProvider({ capabilities = ['spot', 'history_point'], koers = 1.0 } = {}) {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('fixed','Vaste koers','vaste koers','','none',0,1,1,1,'2026-08-20T10:00:00.000Z')`);
|
||||
const id = lastId();
|
||||
run(`INSERT INTO ProviderFixedRate (providerId, fiatPrice) VALUES (?, ?)`, [id, koers]);
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'EUR')`, [id]);
|
||||
for (const capability of capabilities) {
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate)
|
||||
VALUES (?, ?, 'GET', '', NULL)`, [id, capability]);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
function bronkeuze(providerId, capabilities) {
|
||||
for (const capability of capabilities) {
|
||||
run(`INSERT OR IGNORE INTO PriceSource (assetId, capability, providerId, rank, status)
|
||||
VALUES (?, ?, ?, 1, 'confirmed')`, [eur, capability === 'spot' ? 'spot' : 'history', providerId]);
|
||||
}
|
||||
}
|
||||
const asset = () => one(`SELECT * FROM Asset WHERE id = ?`, [eur]);
|
||||
const prijsrijen = () => all(
|
||||
`SELECT Asset.symbol, Provider.key AS bron, PriceHistory.fiatPrice, PriceHistory.resolution
|
||||
FROM PriceHistory
|
||||
JOIN Asset ON Asset.id = PriceHistory.assetId
|
||||
JOIN Provider ON Provider.id = PriceHistory.providerId
|
||||
ORDER BY PriceHistory.resolution, PriceHistory.observedAt`);
|
||||
|
||||
beforeEach(() => {
|
||||
harness.recreate();
|
||||
facade.clearSpotCache();
|
||||
fetchCalls = [];
|
||||
run(`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit)
|
||||
VALUES ('EUR','Euro','euro','fiat','EUR','cent',2,'unit')`);
|
||||
eur = lastId();
|
||||
});
|
||||
|
||||
describe('vaste koers: de spot komt uit de provider en niet van het netwerk', () => {
|
||||
it('geeft 1,00 als spot, in het ene prijsveld', () => {
|
||||
bronkeuze(vasteKoersProvider(), ['spot']);
|
||||
return facade.liveSpotFor(asset()).then(spot => {
|
||||
assert.deepEqual(spot, { price: 1 });
|
||||
assert.deepEqual(fetchCalls, [], 'er is niets naar buiten gegaan');
|
||||
});
|
||||
});
|
||||
|
||||
it('meet geen api-call, want er is geen api-call', async () => {
|
||||
bronkeuze(vasteKoersProvider(), ['spot']);
|
||||
await facade.liveSpotFor(asset());
|
||||
await facade.liveSpotFor(asset());
|
||||
assert.deepEqual(all(`SELECT * FROM ApiCallMetric`), []);
|
||||
});
|
||||
|
||||
it('legt de huidige waarde wél vast als tijdstip-punt', async () => {
|
||||
bronkeuze(vasteKoersProvider(), ['spot']);
|
||||
await facade.liveSpotFor(asset());
|
||||
assert.deepEqual(prijsrijen(),
|
||||
[{ symbol: 'EUR', bron: 'fixed', fiatPrice: 1, resolution: 'timestamp' }]);
|
||||
});
|
||||
|
||||
it('laat een gewone provider wél naar buiten gaan', async () => {
|
||||
run(`INSERT INTO Provider (key, name, searchName, baseUrl, authenticationKind, authenticationRequired,
|
||||
isActive, isSeed, isSystem, createdAt)
|
||||
VALUES ('mempool','mempool','mempool','https://voorbeeld.test','none',0,1,1,0,'2026-08-20T10:00:00.000Z')`);
|
||||
const id = lastId();
|
||||
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'EUR')`, [id]);
|
||||
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate)
|
||||
VALUES (?, 'spot', 'GET', '/prijs', NULL)`, [id]);
|
||||
bronkeuze(id, ['spot']);
|
||||
|
||||
assert.equal(await facade.liveSpotFor(asset()), null, 'de stub gooit, dus geen koers');
|
||||
assert.equal(fetchCalls.length, 1, 'en er is wél een verzoek gedaan');
|
||||
assert.equal(fetchCalls[0].url, '/api/price/call');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vaste koers: de historie', () => {
|
||||
it('vult price_history met een dagpunt van 1,00 via de spot-keten', async () => {
|
||||
bronkeuze(vasteKoersProvider(), ['spot', 'history_point']);
|
||||
await facade.ensurePriceHistoryFor(asset());
|
||||
assert.deepEqual(prijsrijen().filter(r => r.resolution === 'day'),
|
||||
[{ symbol: 'EUR', bron: 'fixed', fiatPrice: 1, resolution: 'day' }]);
|
||||
assert.deepEqual(fetchCalls, []);
|
||||
});
|
||||
|
||||
it('levert een punt voor élke datum uit het verleden, met het tijdstip van die dag erbij', async () => {
|
||||
bronkeuze(vasteKoersProvider(), ['history_point']);
|
||||
assert.deepEqual(await facade.historicalPointForVia(asset(), '2019-12-31'),
|
||||
{ price: 1, time: Date.parse('2019-12-31T00:00:00Z') / 1000 });
|
||||
assert.deepEqual(await facade.historicalPointForVia(asset(), '2026-01-01'),
|
||||
{ price: 1, time: Date.parse('2026-01-01T00:00:00Z') / 1000 });
|
||||
assert.deepEqual(fetchCalls, []);
|
||||
});
|
||||
|
||||
it('slaat een punt op voor elke gevraagde peildatum', async () => {
|
||||
bronkeuze(vasteKoersProvider(), ['history_point']);
|
||||
await facade.ensurePointsAtFor(asset(), ['2025-01-01', '2026-01-01']);
|
||||
assert.deepEqual(
|
||||
all(`SELECT observedAt, fiatPrice, resolution FROM PriceHistory ORDER BY observedAt`),
|
||||
[{ observedAt: '2025-01-01T00:00:00Z', fiatPrice: 1, resolution: 'day' },
|
||||
{ observedAt: '2026-01-01T00:00:00Z', fiatPrice: 1, resolution: 'day' }]);
|
||||
assert.deepEqual(fetchCalls, []);
|
||||
});
|
||||
|
||||
it('heeft geen daggrid, en zegt dat ook', async () => {
|
||||
bronkeuze(vasteKoersProvider(), ['spot', 'history_point']);
|
||||
assert.equal(await facade.hasDailyGridFor(asset()), false);
|
||||
});
|
||||
|
||||
it('weigert een reeks te verzinnen als een vaste koers tóch daily_history claimt', async () => {
|
||||
bronkeuze(vasteKoersProvider({ capabilities: ['daily_history'] }), ['daily_history']);
|
||||
await assert.rejects(() => facade.ensurePriceHistoryFor(asset()), /vaste koers/);
|
||||
assert.deepEqual(prijsrijen(), [], 'en er staat niets verzonnen in de historie');
|
||||
});
|
||||
|
||||
it('gebruikt de koers uit de tabel en niet een 1 uit de code', async () => {
|
||||
bronkeuze(vasteKoersProvider({ koers: 0.5 }), ['spot']);
|
||||
assert.deepEqual(await facade.liveSpotFor(asset()), { price: 0.5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Generated, do not edit. sha256:5b9dd8675758ba64b85bca5e16a855027af470810bba49c97e909a501ef3fbbd
|
||||
import { describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDbHarness } from '../helpers/db-node.mjs';
|
||||
|
||||
const harness = createDbHarness();
|
||||
mock.module('../../server/db.js', { namedExports: harness.exports });
|
||||
|
||||
const { firstReceiveAddress } = await import('../../client/price/wallet-scanner.js');
|
||||
|
||||
const VECTOREN = [
|
||||
['p2pkh',
|
||||
'xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj',
|
||||
'1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA'],
|
||||
['p2sh-p2wpkh',
|
||||
'ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP',
|
||||
'37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf'],
|
||||
['p2wpkh',
|
||||
'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs',
|
||||
'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu'],
|
||||
['p2tr',
|
||||
'xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ',
|
||||
'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr'],
|
||||
];
|
||||
|
||||
describe('firstReceiveAddress: BIP-testvectoren via de gevendorde noble/scure-bundel', () => {
|
||||
for (const [scriptType, extKey, verwacht] of VECTOREN) {
|
||||
it(`leidt het eerste ontvangstadres af voor ${scriptType}`, () => {
|
||||
assert.equal(firstReceiveAddress(extKey, scriptType), verwacht);
|
||||
});
|
||||
}
|
||||
|
||||
it('gooit bij een sleutel met een kapotte checksum (geen stil verkeerd adres)', () => {
|
||||
const [, zpub] = VECTOREN[2];
|
||||
const kapot = zpub.slice(0, -1) + (zpub.endsWith('s') ? 't' : 's');
|
||||
assert.throws(() => firstReceiveAddress(kapot, 'p2wpkh'));
|
||||
});
|
||||
|
||||
it('valt bij een onbekend scripttype terug op p2pkh in plaats van te gooien', () => {
|
||||
const [, xpub, p2pkhAdres] = VECTOREN[0];
|
||||
assert.equal(firstReceiveAddress(xpub, 'p2wsh-bestaat-niet'), p2pkhAdres);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Generated, do not edit. sha256:f9a7d616a0b0b96337b4d9aa3810013fc2a8851cf64050bbc67d182adee97bf4
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { zapContext, compactMemo, npub, npubShort, memoNote, MEMO_MAX } from '../../shared/zap.js';
|
||||
|
||||
const ALICE = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
|
||||
const BOB = 'a'.repeat(64);
|
||||
const verzoek = (extra = {}) => JSON.stringify({
|
||||
kind: 9734, pubkey: ALICE, content: 'Mooi stuk!', created_at: 1,
|
||||
tags: [['p', BOB], ['e', 'b'.repeat(64)], ['relays', 'wss://a', 'wss://b'], ['amount', '21000']], sig: 'x', ...extra,
|
||||
});
|
||||
|
||||
describe('npub', () => {
|
||||
it('geeft de npub van NIP-19 voor een bekende sleutel', () => {
|
||||
assert.equal(npub(ALICE), 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6');
|
||||
assert.equal(npubShort(ALICE), 'npub180cv…h6w6');
|
||||
});
|
||||
|
||||
it('weigert wat geen sleutel van 32 bytes is', () => {
|
||||
assert.equal(npub('abc'), null);
|
||||
assert.equal(npub(null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zapContext', () => {
|
||||
it('leest afzender, ontvanger, bericht en de gezapte notitie', () => {
|
||||
assert.deepEqual(zapContext(verzoek()), { sender: ALICE, recipient: BOB, message: 'Mooi stuk!', note: 'b'.repeat(64) });
|
||||
});
|
||||
|
||||
it('een gewone memo, ander JSON of een ander soort event is geen zap', () => {
|
||||
assert.equal(zapContext('Koffie'), null);
|
||||
assert.equal(zapContext('{"kind":1,"content":"x"}'), null);
|
||||
assert.equal(zapContext('{kapot'), null);
|
||||
assert.equal(zapContext(null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactMemo', () => {
|
||||
it('houdt van een zap-verzoek alleen wat gelezen wordt, en de context blijft gelijk', () => {
|
||||
const klein = compactMemo(verzoek());
|
||||
assert.ok(!klein.includes('relays') && !klein.includes('sig'));
|
||||
assert.deepEqual(zapContext(klein), zapContext(verzoek()));
|
||||
});
|
||||
|
||||
it('kort een lange memo in en laat een lege weg', () => {
|
||||
assert.equal(compactMemo('x'.repeat(MEMO_MAX + 50)).length, MEMO_MAX);
|
||||
assert.equal(compactMemo(' '), null);
|
||||
assert.equal(compactMemo(undefined), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoNote', () => {
|
||||
it('noemt bij binnenkomend de afzender en bij uitgaand de ontvanger', () => {
|
||||
assert.equal(memoNote(verzoek(), 'in'), 'zap from npub180cv…h6w6: "Mooi stuk!"');
|
||||
assert.match(memoNote(verzoek(), 'out'), /^zap to npub1/);
|
||||
assert.equal(memoNote(verzoek({ content: '' }), 'in'), 'zap from npub180cv…h6w6');
|
||||
});
|
||||
|
||||
it('een gewone memo komt zoals hij is, en niets geeft niets', () => {
|
||||
assert.equal(memoNote('Koffie', 'out'), 'Koffie');
|
||||
assert.equal(memoNote(null, 'in'), null);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user