374 lines
17 KiB
JavaScript
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');
|
|
});
|
|
});
|