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