Files
2026-10-29 11:57:29 +01:00

374 lines
17 KiB
JavaScript

// 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');
});
});