Versie 0.3.2

This commit is contained in:
Atreides Assets committed 2026-10-29 15:24:06 +01:00
1 parent e77d8fd373
commit 1cf6eeed29
16 files changed
+551 -44

No files matched your search

+2 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:8874091aa075a54dd49f5d1aad9244d40ef61ac47ce7c83d7d4e346d8133207a // Generated, do not edit. sha256:98f4651e5cb7036965f92ae8206b5dd6cbf62ce4e42388c68825bc8d9de6ca9d
import { configureSharedUi } from './ui/shared-setup.js'; import { configureSharedUi } from './ui/shared-setup.js';
import './edition.js'; import './edition.js';
import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js'; import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js';
@@ -253,8 +253,7 @@ async function boot() {
initCommands(); initCommands();
await renderPage(); await renderPage();
document.documentElement.dataset.app = 'ready'; document.documentElement.dataset.app = 'ready';
finishSplash(); finishSplash().then(maybeStartSetup);
maybeStartSetup();
refreshAttention(); refreshAttention();
listAssets() listAssets()
+2 -1
View File
@@ -1,4 +1,4 @@
/* Generated, do not edit. sha256:36518f0a81f251fc91cc7a50cccd80f8e26c117fb0b6017e85098cc76621857c */ /* Generated, do not edit. sha256:d875d85b0d41e419eb6bae7090608b0914629b07925648a2d82dc7a8e8eb4309 */
:root { :root {
--logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E"); --logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E");
--radius-sm: 8px; --radius-sm: 8px;
@@ -2019,6 +2019,7 @@ html[data-pagetitle="off"] { --guide-top:1.2rem; }
border:0; border-radius:50%; background:transparent; color:var(--text-ter); cursor:pointer; } border:0; border-radius:50%; background:transparent; color:var(--text-ter); cursor:pointer; }
.dialog-eye:hover, .dialog-eye:focus-visible { background:var(--accent-soft); color:var(--accent-text); } .dialog-eye:hover, .dialog-eye:focus-visible { background:var(--accent-soft); color:var(--accent-text); }
.dialog-eye svg { width:1rem; height:1rem; } .dialog-eye svg { width:1rem; height:1rem; }
.dialog:not(:has(.val)) .dialog-eye { display:none; }
.dialog:has(.dialog-close) { position:relative; } .dialog:has(.dialog-close) { position:relative; }
.dialog-close { .dialog-close {
position:absolute; top:0.65rem; right:0.65rem; z-index:3; position:absolute; top:0.65rem; right:0.65rem; z-index:3;
+7 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:13240cf3e3de47963beb3e5b97f3a5cb83beb4a9a635ce540f88a745df4d753d // Generated, do not edit. sha256:a167e18e55f858b6191c5f1c50bdab5b3bf485873744837c0e096e77f639cd0e
import { nl } from './nl.js'; import { nl } from './nl.js';
export const en = { export const en = {
@@ -60,7 +60,7 @@ export const en = {
license: { license: {
lockedTitle: 'Part of the paid version', lockedTitle: 'Part of the paid version',
counter: '{n} of {max}', counter: '{naam} in the free edition: {n} of {max}',
whatIsPaid: 'What does the paid version include?', whatIsPaid: 'What does the paid version include?',
why_develop: 'Development version: nothing is restricted below 1.0.0', why_develop: 'Development version: nothing is restricted below 1.0.0',
why_forced: 'Forced via ASSETAPP_LICENSE_STATE (development only)', why_forced: 'Forced via ASSETAPP_LICENSE_STATE (development only)',
@@ -210,6 +210,9 @@ export const en = {
infoPrivacy: 'Your npub and the orders with it. An email address only if you provide one, and then ' infoPrivacy: 'Your npub and the orders with it. An email address only if you provide one, and then '
+ 'made unrecognizable, so it can only be matched if you come forward with it yourself.', + 'made unrecognizable, so it can only be matched if you come forward with it yourself.',
name_fiatAccounts: 'Fiat accounts', name_fiatAccounts: 'Fiat accounts',
name_assets: 'Assets besides currencies',
name_valuations: 'Balances',
name_portfolios: 'Portfolios',
featTitle: 'What the editions include', featTitle: 'What the editions include',
featFull: 'Full', featFull: 'Full',
featFree: 'Free', featFree: 'Free',
@@ -1219,7 +1222,7 @@ export const en = {
groupBy: 'Group', groupBy: 'Group',
standing: 'Current position', standing: 'Current position',
toWealth: 'To Wealth', toWealth: 'To Wealth',
standingNone: 'No tax-relevant positions yet.', standingNone: 'No positions yet. Book a purchase or record a balance.',
showToday: 'Show return', showToday: 'Show return',
}, },
@@ -2084,6 +2087,7 @@ export const en = {
scanning: 'Scanning…', scanning: 'Scanning…',
scanningTx: 'Fetching transactions', scanningTx: 'Fetching transactions',
scanError: 'Update failed', scanError: 'Update failed',
scanErrorWhy: 'Update failed: {reden}',
scanNotOnchain: 'Cannot be scanned on-chain', scanNotOnchain: 'Cannot be scanned on-chain',
lightningMovements: 'Lightning transactions updated', lightningMovements: 'Lightning transactions updated',
addressesScanned: 'addresses scanned', addressesScanned: 'addresses scanned',
+7 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:5337809d822cab071fd28371d6976e564034e1f27296fd058188237da0027ce9 // Generated, do not edit. sha256:f025d672341c54d23ebad8e10876b424ac570cd500126582bf4b9c905f2cd672
export const nl = { export const nl = {
app: { name: 'Atreides Assets' }, app: { name: 'Atreides Assets' },
@@ -58,7 +58,7 @@ export const nl = {
license: { license: {
lockedTitle: 'Hoort bij de betaalde versie', lockedTitle: 'Hoort bij de betaalde versie',
counter: '{n} van {max}', counter: '{naam} in de gratis editie: {n} van {max}',
whatIsPaid: 'Wat zit er in de betaalde versie?', whatIsPaid: 'Wat zit er in de betaalde versie?',
why_develop: 'Ontwikkelversie: er wordt niets beperkt onder 1.0.0', why_develop: 'Ontwikkelversie: er wordt niets beperkt onder 1.0.0',
why_forced: 'Geforceerd via ASSETAPP_LICENSE_STATE (alleen bij ontwikkelen)', why_forced: 'Geforceerd via ASSETAPP_LICENSE_STATE (alleen bij ontwikkelen)',
@@ -210,6 +210,9 @@ export const nl = {
infoPrivacy: 'Uw npub en de orders erbij. Een e-mailadres alleen als u dat opgeeft, en dan onherkenbaar ' infoPrivacy: 'Uw npub en de orders erbij. Een e-mailadres alleen als u dat opgeeft, en dan onherkenbaar '
+ 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.', + 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.',
name_fiatAccounts: 'Fiatrekeningen', name_fiatAccounts: 'Fiatrekeningen',
name_assets: 'Assets naast valuta',
name_valuations: 'Balansen',
name_portfolios: 'Portefeuilles',
featTitle: 'Wat de edities bieden', featTitle: 'Wat de edities bieden',
featFull: 'Volledig', featFull: 'Volledig',
featFree: 'Gratis', featFree: 'Gratis',
@@ -1235,7 +1238,7 @@ export const nl = {
groupBy: 'Groeperen', groupBy: 'Groeperen',
standing: 'Huidige stand', standing: 'Huidige stand',
toWealth: 'Naar Vermogen', toWealth: 'Naar Vermogen',
standingNone: 'Nog geen tax-relevante posities.', standingNone: 'Nog geen posities. Boek een aankoop of leg een balans vast.',
showToday: 'Rendement tonen', showToday: 'Rendement tonen',
}, },
@@ -2108,6 +2111,7 @@ export const nl = {
scanning: 'Scannen…', scanning: 'Scannen…',
scanningTx: 'Transacties ophalen', scanningTx: 'Transacties ophalen',
scanError: 'Bijwerken mislukt', scanError: 'Bijwerken mislukt',
scanErrorWhy: 'Bijwerken mislukt: {reden}',
scanNotOnchain: 'Niet via de keten te scannen', scanNotOnchain: 'Niet via de keten te scannen',
lightningMovements: 'lightning-bewegingen bijgewerkt', lightningMovements: 'lightning-bewegingen bijgewerkt',
addressesScanned: 'adressen gescand', addressesScanned: 'adressen gescand',
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:e2056779c9285b8b0c7aa5d702fc44b067694e358ed2b791b9b58447551ece12 // Generated, do not edit. sha256:1df2c5fd2e650856e801961b8f9b2f63fd3868b6f612cd0c41498384a12aea9b
import { licenseStatus } from './data/license-status.js'; import { licenseStatus } from './data/license-status.js';
import { getPref, setPref } from './data/preferences.js'; import { getPref, setPref } from './data/preferences.js';
import { t } from './i18n/index.js'; import { t } from './i18n/index.js';
@@ -24,7 +24,7 @@ export function limitFor(kind) {
export function counterText(kind, current) { export function counterText(kind, current) {
const max = limitFor(kind); const max = limitFor(kind);
return max === null ? null : t('license.counter', { n: current, max }); return max === null ? null : t('license.counter', { naam: t(`license.name_${kind}`), n: current, max });
} }
const PREF_SCOPE = 'license'; const PREF_SCOPE = 'license';
+6 -4
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:51ab737fb6604c8493f238a8893184408226a1a90ddd465fcd92bf0040b5a50e // Generated, do not edit. sha256:9b4688b90b1d72ebe6c49262c3d23557d132d9066d8379a911157bbf3dae8ae8
import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js'; import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js';
import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js'; import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js';
import { listConnections } from '../data/connections.js'; import { listConnections } from '../data/connections.js';
@@ -102,7 +102,7 @@ async function buildTable(el) {
tableApi = renderTable(el.querySelector('#tbl'), { tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: rijen, columns, rows: rijen,
...(limitFor('fiatAccounts') !== null ...(limitFor('fiatAccounts') !== null
? { toolbarNote: () => `${t('license.name_fiatAccounts')}: ${counterText('fiatAccounts', bankCount)}` } : {}), ? { toolbarNote: () => counterText('fiatAccounts', bankCount) } : {}),
...(await tableViewCfg('accounts', { key: 'name', dir: 'asc' })), ...(await tableViewCfg('accounts', { key: 'name', dir: 'asc' })),
emptyText: t('accounts.none'), emptyText: t('accounts.none'),
noneLabel: t('table.none'), noneLabel: t('table.none'),
@@ -317,6 +317,8 @@ async function mountSources(host, accountId, allowed) {
}; };
let api = null; let api = null;
const reloadSrc = async () => api.refresh(await listWalletSources(accountId)); const reloadSrc = async () => api.refresh(await listWalletSources(accountId));
const rows = await listWalletSources(accountId);
const nodeColumn = !locked('lightning') || rows.some(r => r.connectionId != null);
api = renderTable(host.querySelector('[data-src-table]'), { api = renderTable(host.querySelector('[data-src-table]'), {
title: t('accounts.sources'), title: t('accounts.sources'),
@@ -348,8 +350,8 @@ async function mountSources(host, accountId, allowed) {
{ key: 'scriptType', label: t('accounts.colScriptType'), { key: 'scriptType', label: t('accounts.colScriptType'),
render: s => s.kind === 'xpub' ? t('accounts.script_' + s.scriptType) : '<span class="t-muted-cell">-</span>', render: s => s.kind === 'xpub' ? t('accounts.script_' + s.scriptType) : '<span class="t-muted-cell">-</span>',
edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } }, edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } },
], ].filter(c => nodeColumn || c.key !== 'connectionId'),
rows: await listWalletSources(accountId), rows,
...(await tableViewCfg('accounts.sources', { key: 'name', dir: 'asc' })), ...(await tableViewCfg('accounts.sources', { key: 'name', dir: 'asc' })),
emptyText: t('accounts.noSources'), emptyText: t('accounts.noSources'),
newLabel: t('table.new'), newLabel: t('table.new'),
+12 -5
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:7de3dc53eab20f070a6ac8c7162fee1bd0695f724c6a13e314a994181782906e // Generated, do not edit. sha256:26e9a2b26a7c0fa9e0b6979080609ba55baa351ce8440240a006def072475893
import { listAssets, listBookableAssets, createAsset, updateAsset, deleteAsset } from '../data/assets.js'; import { listAssets, listBookableAssets, createAsset, updateAsset, deleteAsset } from '../data/assets.js';
import { hasMetalAssets } from '../data/metals.js'; import { hasMetalAssets } from '../data/metals.js';
import { tableViewCfg } from '../data/preferences.js'; import { tableViewCfg } from '../data/preferences.js';
@@ -132,7 +132,14 @@ async function buildTable(el) {
}; };
} }
const rijen = await listAssets(); const nieuw = new Set();
const zichtbaar = async () => {
const b = await listBookableAssets();
if (!nieuw.size) return b;
const ids = new Set(b.map(a => a.id));
return [...b, ...(await listAssets()).filter(a => nieuw.has(a.id) && !ids.has(a.id))];
};
const rijen = await zichtbaar();
const bookable = async () => (await listBookableAssets()).filter(r => r.symbol !== baseCurrency()).length; const bookable = async () => (await listBookableAssets()).filter(r => r.symbol !== baseCurrency()).length;
let used = await bookable(); let used = await bookable();
tableApi = renderTable(el.querySelector('#tbl'), { tableApi = renderTable(el.querySelector('#tbl'), {
@@ -145,11 +152,11 @@ async function buildTable(el) {
newLabel: t('table.new'), newLabel: t('table.new'),
newDefaults: { kind: 'crypto', decimals: '', displayUnit: 'unit', taxCategory: '', taxScope: '', balanceCapture: '' }, newDefaults: { kind: 'crypto', decimals: '', displayUnit: 'unit', taxCategory: '', taxScope: '', balanceCapture: '' },
saveLabel: t('assets.save'), cancelLabel: t('assets.cancel'), saveLabel: t('assets.save'), cancelLabel: t('assets.cancel'),
onCreate: async v => { const id = await createAsset(toPayload(v)); used = await bookable(); tableApi.refresh(await listAssets(), { locate: id }); }, onCreate: async v => { const id = await createAsset(toPayload(v)); nieuw.add(id); used = await bookable(); tableApi.refresh(await zichtbaar(), { locate: id }); },
onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await listAssets()); }, onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await zichtbaar()); },
onDelete: async id => { onDelete: async id => {
if (!(await dialog.confirm({ message: t('assets.confirmDelete'), danger: true }))) return; if (!(await dialog.confirm({ message: t('assets.confirmDelete'), danger: true }))) return;
try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await listAssets()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await zichtbaar()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
}, },
}); });
tableApi.focusRow(takeRowFocus('assets')); tableApi.focusRow(takeRowFocus('assets'));
+6 -5
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:fcc1131e05f7794fbe4f303c8c1237f96201e5ee9ed17491fd18d55b64828d40 // Generated, do not edit. sha256:bcb22b0408193ecc7c34d5e55a86c15a89be89fdd2d96b8d21f03771f4179be1
import { localDate } from '../../shared/time.js'; import { localDate } from '../../shared/time.js';
import { listAccounts } from '../data/accounts.js'; import { listAccounts } from '../data/accounts.js';
import { listLabels, createLabel } from '../data/labels.js'; import { listLabels, createLabel } from '../data/labels.js';
@@ -553,18 +553,18 @@ async function scanAll(el) {
const r = await scanLightningSource(s); const r = await scanLightningSource(s);
ln += r.count; ln += r.count;
if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]); if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]);
} catch (err) { console.error('[lightning-scan]', err); error = true; } } catch (err) { console.error('[lightning-scan]', err); error = err.message || true; }
continue; continue;
} }
if (!isOnchainSource(s)) { geslagen++; continue; } if (!isOnchainSource(s)) { geslagen++; continue; }
try { try {
const r = await scanOne(el, s, g.id); const r = await scanOne(el, s, g.id);
count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0); count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0);
} catch (err) { console.error('[onchain-scan-all]', err); error = true; } } catch (err) { console.error('[onchain-scan-all]', err); error = err.message || true; }
} }
const skipped = geslagen && !lnBronnen && !count && !failed; const skipped = geslagen && !lnBronnen && !count && !failed;
if (error) nMislukt++; else if (!skipped) nWallets++; if (error) nMislukt++; else if (!skipped) nWallets++;
results[g.id] = error ? { error: true } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen }); results[g.id] = error ? { error } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen });
} }
if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err)); if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err));
for (const [accountId, txids] of kanalen) { for (const [accountId, txids] of kanalen) {
@@ -582,7 +582,8 @@ async function scanAll(el) {
zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt)); zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt));
for (const id in results) { for (const id in results) {
const r = results[id]; const r = results[id];
if (r.error) setLogRow(el, id, 't-neg', t('tracking.scanError')); if (r.error) setLogRow(el, id, 't-neg', typeof r.error === 'string'
? t('tracking.scanErrorWhy', { reden: r.error }) : t('tracking.scanError'));
else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain')); else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain'));
else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r)); else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r));
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// Generated, do not edit. sha256:4bf6f8ef0a0bf339d290a677844034b6b894f341f060833980b2dc5ccb489b09 // Generated, do not edit. sha256:d2bc926ea0bee8d8465cb325428dc8ec0f61a809df8e09696d2ce30fb50111c2
import { esc } from './escape.js'; import { esc } from './escape.js';
import { SHIELD } from './icons.js'; import { SHIELD } from './icons.js';
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
import { TARGETS, targetHash } from '../../shared/config.js'; import { TARGETS, targetHash } from '../../shared/config.js';
import { limitFor } from '../license.js'; import { limitFor, counterText } from '../license.js';
export function lockIcon(title = t('license.lockedTitle')) { export function lockIcon(title = t('license.lockedTitle')) {
return `<span class="lock-icon" title="${esc(title)}" aria-label="${esc(title)}">${SHIELD}</span>`; return `<span class="lock-icon" title="${esc(title)}" aria-label="${esc(title)}">${SHIELD}</span>`;
@@ -31,7 +31,7 @@ export function capacityCfg(kind, used) {
const max = limitFor(kind); const max = limitFor(kind);
if (max === null) return {}; if (max === null) return {};
return { return {
toolbarNote: () => t('license.counter', { n: used(), max }), toolbarNote: () => counterText(kind, used()),
createHidden: () => used() >= max, createHidden: () => used() >= max,
}; };
} }
+5 -5
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:a433ae61cf1d51dd5a396f13922a2aaa329bfea96e00b35d9456a671a84c5e04 // Generated, do not edit. sha256:45c5f1058c49379b8b6b1e8f53d554bf4f5826219c9349649a60020eb36c8064
import { CONFIG } from '../../shared/config.js'; import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
@@ -24,10 +24,10 @@ let typeTimer = 0;
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function fadeOut(splash) { function fadeOut(splash, after = () => {}) {
splash.style.transitionDuration = `${CONFIG.SPLASH.FADE_MS}ms`; splash.style.transitionDuration = `${CONFIG.SPLASH.FADE_MS}ms`;
splash.classList.add('is-done'); splash.classList.add('is-done');
setTimeout(() => reset(splash), CONFIG.SPLASH.FADE_MS + 50); setTimeout(() => { reset(splash); after(); }, CONFIG.SPLASH.FADE_MS + 50);
} }
function reset(splash) { function reset(splash) {
@@ -41,9 +41,9 @@ function reset(splash) {
export function finishSplash() { export function finishSplash() {
const splash = document.getElementById('splash'); const splash = document.getElementById('splash');
if (!splash || document.documentElement.dataset.splash !== 'on') return; if (!splash || document.documentElement.dataset.splash !== 'on') return Promise.resolve();
const wait = Math.max(0, CONFIG.SPLASH.MIN_MS - (performance.now() - bootT0)); const wait = Math.max(0, CONFIG.SPLASH.MIN_MS - (performance.now() - bootT0));
setTimeout(() => fadeOut(splash), wait); return new Promise(resolve => setTimeout(() => fadeOut(splash, resolve), wait));
} }
function showManualSplash() { function showManualSplash() {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "assetapp", "name": "assetapp",
"version": "0.2.0", "version": "0.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "assetapp", "name": "assetapp",
"version": "0.2.0", "version": "0.3.2",
"dependencies": { "dependencies": {
"@noble/curves": "2.4.0" "@noble/curves": "2.4.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "assetapp", "name": "assetapp",
"version": "0.2.0", "version": "0.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+9 -4
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:31a0448818ec43a013271278ccfca034b12a6cd78e023ce301c87ce6d267b944 // Generated, do not edit. sha256:30f785c6890a03397971aad68496979bd9657deab8cb4a7a61fd0569a7cf87a8
import { randomUUID, createHash } from 'node:crypto'; import { randomUUID, createHash } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { readFile, writeFile, mkdir } from 'node:fs/promises';
@@ -342,18 +342,23 @@ export function createMarketClient({
const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to }); const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to });
rid = requestId; rid = requestId;
const cols = Array.isArray(json.columns) ? json.columns : []; const cols = Array.isArray(json.columns) ? json.columns : [];
const [ti, pi, ri] = ['t', 'price', 'resolution'].map(c => cols.indexOf(c)); const [ti, pi, ri, si] = ['t', 'price', 'resolution', 'source'].map(c => cols.indexOf(c));
if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) { if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.'); fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.');
} }
if (meta && json.currency !== meta.currency) { if (meta && json.currency !== meta.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.'); fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.');
} }
meta ??= { instrument, currency: json.currency, source: json.source || null }; meta ??= { instrument, currency: json.currency, source: json.source || null, sourceLabels: {} };
if (json.sourceLabels && typeof json.sourceLabels === 'object') {
for (const [key, label] of Object.entries(json.sourceLabels)) if (typeof label === 'string' && label) meta.sourceLabels[key] = label;
}
for (const r of json.rows) { for (const r of json.rows) {
const t = Number(r?.[ti]); const t = Number(r?.[ti]);
const p = Number(r?.[pi]); const p = Number(r?.[pi]);
if (Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0) points.push([t, p, r[ri]]); if (!(Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0)) continue;
const src = si >= 0 && typeof r[si] === 'string' && r[si] ? r[si] : null;
points.push(src ? [t, p, r[ri], src] : [t, p, r[ri]]);
} }
if (json.next == null || !(Number(json.next) > cursor)) break; if (json.next == null || !(Number(json.next) > cursor)) break;
cursor = Number(json.next); cursor = Number(json.next);
+451
View File
@@ -0,0 +1,451 @@
// Generated, do not edit. sha256:b010e85d240007ea64c04ad8392259f493d1479d73de4754cba27fd27e215811
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { createRequestListener } from '../../server/app.mjs';
import {
passwordMatches, parseCookies, sessionCookie, clearedSessionCookie, sessionTokenOf, resolveBinding,
} from '../../server/auth.mjs';
import { createSessionStore, createAttemptLimiter } from '../../server/sessions.mjs';
import { CONFIG } from '../../shared/config.js';
const GELDIG = { password: 'geheim' };
function serverMet(opties) {
const ctx = {};
before(async () => {
ctx.server = createServer(createRequestListener(opties));
await new Promise(klaar => ctx.server.listen(0, '127.0.0.1', klaar));
ctx.basis = `http://127.0.0.1:${ctx.server.address().port}`;
});
after(() => new Promise(klaar => ctx.server.close(klaar)));
ctx.haal = (pad, init = {}) => fetch(`${ctx.basis}${pad}`, { redirect: 'manual', ...init });
ctx.login = (wachtwoord) => ctx.haal('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: wachtwoord }),
});
return ctx;
}
const cookieUit = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
describe('passwordMatches', () => {
it('accepteert het juiste wachtwoord en weigert een verkeerd', () => {
assert.equal(passwordMatches('geheim', GELDIG), true);
assert.equal(passwordMatches('fout', GELDIG), false);
});
it('weigert alles zodra er niets is ingesteld of het ingestelde leeg is', () => {
assert.equal(passwordMatches('', { password: '' }), false);
assert.equal(passwordMatches('watdanook', { password: '' }), false);
assert.equal(passwordMatches('watdanook', null), false);
assert.equal(passwordMatches(null, GELDIG), false);
assert.equal(passwordMatches(undefined, GELDIG), false);
});
it('accepteert geen niet-string die toevallig gelijk oogt', () => {
assert.equal(passwordMatches({ toString: () => 'geheim' }, GELDIG), false);
assert.equal(passwordMatches(['geheim'], GELDIG), false);
});
it('behandelt niet-ASCII en dubbele punten gewoon als tekens', () => {
assert.equal(passwordMatches('a:b:c', { password: 'a:b:c' }), true);
assert.equal(passwordMatches('wachtwoord€', { password: 'wachtwoord€' }), true);
});
});
describe('parseCookies', () => {
it('leest een enkel cookie en meerdere naast elkaar', () => {
assert.deepEqual(parseCookies('a=1'), { a: '1' });
assert.deepEqual(parseCookies('a=1; b=2'), { a: '1', b: '2' });
});
it('slaat brokken zonder = over in plaats van om te vallen', () => {
assert.deepEqual(parseCookies('rommel; a=1'), { a: '1' });
assert.deepEqual(parseCookies(''), {});
assert.deepEqual(parseCookies(undefined), {});
});
it('laat de EERSTE waarde winnen bij een dubbele naam', () => {
assert.equal(parseCookies('s=echt; s=vals').s, 'echt');
});
it('laat een waarde met een = erin heel', () => {
assert.equal(parseCookies('s=a=b').s, 'a=b');
});
});
describe('het sessiecookie', () => {
it('draagt HttpOnly, SameSite en een Max-Age die bij de sessie past', () => {
const c = sessionCookie('abc');
assert.match(c, /^assetapp_session=abc;/);
assert.match(c, /HttpOnly/);
assert.match(c, /SameSite=Lax/);
assert.match(c, new RegExp(`Max-Age=${Math.floor(CONFIG.AUTH.SESSION_TTL_MS / 1000)}\\b`));
});
it('krijgt Secure alleen mét TLS', () => {
assert.doesNotMatch(sessionCookie('abc', { secure: false }), /Secure/);
assert.match(sessionCookie('abc', { secure: true }), /Secure/);
});
it('haalt zichzelf weg met dezelfde vlaggen als waarmee hij gezet is', () => {
const gezet = sessionCookie('abc', { secure: true });
const weg = clearedSessionCookie({ secure: true });
const vlaggenVan = (c) => c.split(';').slice(1).map(d => d.trim()).filter(d => !d.startsWith('Max-Age'));
assert.deepEqual(vlaggenVan(weg), vlaggenVan(gezet));
assert.match(weg, /Max-Age=0/);
});
it('leest het token terug uit een verzoek, en geeft null zonder cookie', () => {
assert.equal(sessionTokenOf({ headers: { cookie: 'assetapp_session=xyz' } }), 'xyz');
assert.equal(sessionTokenOf({ headers: { cookie: 'iets=anders' } }), null);
assert.equal(sessionTokenOf({ headers: {} }), null);
assert.equal(sessionTokenOf(undefined), null);
});
});
describe('createSessionStore', () => {
it('geeft een vers token dat geldig is, en twee keer niet hetzelfde', () => {
const s = createSessionStore();
const a = s.create();
const b = s.create();
assert.notEqual(a, b);
assert.equal(s.isValid(a), true);
assert.equal(s.isValid(b), true);
});
it('weigert een token dat hij nooit heeft uitgegeven', () => {
const s = createSessionStore();
s.create();
assert.equal(s.isValid('a'.repeat(64)), false);
assert.equal(s.isValid(''), false);
assert.equal(s.isValid(undefined), false);
assert.equal(s.isValid(null), false);
assert.equal(s.isValid(123), false);
});
it('laat een token vervallen zodra de tijd om is', () => {
let nu = 1000;
const s = createSessionStore({ ttlMs: 100, now: () => nu });
const token = s.create();
nu = 1099;
assert.equal(s.isValid(token), true, 'net vóór de grens telt hij nog');
nu = 1100;
assert.equal(s.isValid(token), false, 'op de grens is hij voorbij');
});
it('verlengt een sessie NIET door hem te gebruiken', () => {
let nu = 0;
const s = createSessionStore({ ttlMs: 100, now: () => nu });
const token = s.create();
for (nu = 10; nu < 100; nu += 10) assert.equal(s.isValid(token), true);
nu = 100;
assert.equal(s.isValid(token), false);
});
it('ruimt een verlopen token op in plaats van hem te laten liggen', () => {
let nu = 0;
const s = createSessionStore({ ttlMs: 100, now: () => nu });
s.create();
nu = 200;
s.create();
assert.equal(s.size(), 1, 'de verlopen sessie hoort bij het aanmaken opgeruimd te zijn');
});
it('beëindigt een sessie met drop, en een onbekend token is geen fout', () => {
const s = createSessionStore();
const token = s.create();
assert.equal(s.drop(token), true);
assert.equal(s.isValid(token), false);
assert.equal(s.drop('bestaat-niet'), false);
});
it('deelt niets tussen twee opslagen', () => {
const a = createSessionStore();
const b = createSessionStore();
assert.equal(b.isValid(a.create()), false);
});
});
describe('createAttemptLimiter', () => {
it('laat de eerste pogingen vrij en gaat daarna oplopend wachten', () => {
let nu = 0;
const l = createAttemptLimiter({ free: 3, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
const adres = '127.0.0.1';
for (let i = 0; i < 3; i++) { l.fail(adres); assert.equal(l.retryAfterMs(adres), 0); }
assert.equal(l.fail(adres), 100, 'de vierde misser kost de basiswachttijd');
assert.equal(l.fail(adres), 200, 'en daarna verdubbelt het');
assert.equal(l.fail(adres), 400);
});
it('laat de wachttijd aflopen met de tijd', () => {
let nu = 0;
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
l.fail('a');
assert.equal(l.retryAfterMs('a'), 100);
nu = 60;
assert.equal(l.retryAfterMs('a'), 40);
nu = 100;
assert.equal(l.retryAfterMs('a'), 0);
});
it('zet de teller NIET terug door de wachttijd uit te zitten', () => {
let nu = 0;
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
l.fail('a');
nu = 100;
assert.equal(l.retryAfterMs('a'), 0);
assert.equal(l.fail('a'), 200, 'de volgende misser bouwt voort op de vorige');
});
it('gaat niet boven het maximum uit', () => {
let nu = 0;
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu });
for (let i = 0; i < 20; i++) l.fail('a');
assert.equal(l.retryAfterMs('a'), 250);
});
it('telt per adres en niet over adressen heen', () => {
let nu = 0;
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
l.fail('a'); l.fail('a'); l.fail('a');
assert.equal(l.retryAfterMs('b'), 0);
});
it('vergeet een adres na een geslaagde poging', () => {
let nu = 0;
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
l.fail('a');
l.succeed('a');
assert.equal(l.retryAfterMs('a'), 0);
assert.equal(l.size(), 0);
});
it('vergeet een adres dat lang genoeg stil is, zodat de lijst niet eindeloos groeit', () => {
let nu = 0;
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu });
l.fail('a');
nu = 100;
assert.equal(l.size(), 1, 'net uitgezeten is nog niet vergeten');
nu = 100000;
l.retryAfterMs('a');
assert.equal(l.size(), 0);
});
});
describe('resolveBinding', () => {
it('bindt op alle interfaces zodra TLS én inloggegevens er zijn', () => {
assert.deepEqual(resolveBinding({ tls: true, credentials: GELDIG }), { host: '0.0.0.0', waarschuwing: null });
});
it('bindt op loopback zolang een van de twee ontbreekt', () => {
assert.equal(resolveBinding({ tls: false, credentials: GELDIG }).host, '127.0.0.1');
assert.equal(resolveBinding({ tls: true, credentials: null }).host, '127.0.0.1');
assert.equal(resolveBinding({}).host, '127.0.0.1');
});
it('volgt een expliciete host, maar waarschuwt als die onbeschermd buiten loopback ligt', () => {
const uit = resolveBinding({ tls: false, credentials: null, host: '0.0.0.0' });
assert.equal(uit.host, '0.0.0.0');
assert.match(uit.waarschuwing, /TLS en inloggegevens/);
});
it('noemt in de waarschuwing alleen wat er werkelijk ontbreekt', () => {
const uit = resolveBinding({ tls: true, credentials: null, host: '192.168.1.50' });
assert.match(uit.waarschuwing, /inloggegevens ontbreekt/);
assert.doesNotMatch(uit.waarschuwing, /TLS/);
});
it('waarschuwt niet bij een expliciete loopback-host', () => {
for (const host of ['127.0.0.1', '::1', 'localhost']) {
assert.equal(resolveBinding({ tls: false, credentials: null, host }).waarschuwing, null);
}
});
});
describe('createRequestListener: alles zit achter de sessiecontrole', () => {
const ctx = serverMet({ credentials: GELDIG });
it('weigert /api/health zonder cookie, met een kale 401', async () => {
const r = await ctx.haal('/api/health');
assert.equal(r.status, 401);
});
it('stuurt op een DOCUMENTverzoek om naar het inlogscherm', async () => {
const r = await ctx.haal('/');
assert.equal(r.status, 302);
assert.equal(r.headers.get('location'), CONFIG.AUTH.LOGIN_PATH);
});
it('stuurt op een API-verzoek NIET om, want fetch zou die omleiding volgen', async () => {
const r = await ctx.haal('/api/data/assets/listAssets', { method: 'POST' });
assert.equal(r.status, 401);
assert.equal(r.headers.get('location'), null);
});
it('weigert een onbekend /api-pad ook vóór de router, dus zonder 404 te verklappen', async () => {
const r = await ctx.haal('/api/bestaat-niet');
assert.equal(r.status, 401);
});
it('draagt NERGENS een WWW-Authenticate-header', async () => {
for (const pad of ['/', '/api/health', '/api/bestaat-niet', '/css/styles.css']) {
const r = await ctx.haal(pad);
assert.equal(r.headers.get('www-authenticate'), null, `${pad} draagt de header nog`);
}
});
});
describe('createRequestListener: inloggen en uitloggen', () => {
const ctx = serverMet({ credentials: GELDIG });
it('weigert een verkeerd wachtwoord zonder cookie te zetten', async () => {
const r = await ctx.login('fout');
assert.equal(r.status, 401);
assert.equal(r.headers.get('set-cookie'), null);
});
it('zet bij het juiste wachtwoord een HttpOnly-sessiecookie', async () => {
const r = await ctx.login('geheim');
assert.equal(r.status, 200);
const cookie = r.headers.get('set-cookie');
assert.match(cookie, /^assetapp_session=[0-9a-f]{64};/);
assert.match(cookie, /HttpOnly/);
});
it('opent daarmee de app én de API', async () => {
const cookie = cookieUit(await ctx.login('geheim'));
const app = await ctx.haal('/', { headers: { cookie } });
assert.equal(app.status, 200);
const api = await ctx.haal('/api/health', { headers: { cookie } });
assert.equal(api.status, 200);
assert.deepEqual(await api.json(), { ok: true });
});
it('weigert een verzonnen cookie', async () => {
const r = await ctx.haal('/api/health', { headers: { cookie: `assetapp_session=${'a'.repeat(64)}` } });
assert.equal(r.status, 401);
});
it('maakt het cookie ongeldig bij uitloggen, aan beide kanten', async () => {
const cookie = cookieUit(await ctx.login('geheim'));
const uit = await ctx.haal('/api/logout', { method: 'POST', headers: { cookie } });
assert.equal(uit.status, 200);
assert.match(uit.headers.get('set-cookie'), /Max-Age=0/, 'de browser hoort het cookie weg te gooien');
const na = await ctx.haal('/api/health', { headers: { cookie } });
assert.equal(na.status, 401);
});
it('stuurt een ingelogde bezoeker van het inlogscherm door naar de app', async () => {
const cookie = cookieUit(await ctx.login('geheim'));
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH, { headers: { cookie } });
assert.equal(r.status, 302);
assert.equal(r.headers.get('location'), '/');
});
it('weigert GET op de inlog- en uitlogroute', async () => {
assert.equal((await ctx.haal('/api/login')).status, 405);
assert.equal((await ctx.haal('/api/logout')).status, 405);
});
it('laat het formulier ook zonder JavaScript geen wachtwoord in de URL zetten', async () => {
const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8');
const form = /<form[^>]*id="login-form"[^>]*>/.exec(html);
assert.ok(form, 'geen formulier met id login-form gevonden');
assert.match(form[0], /method="post"/i);
assert.match(form[0], /action="\/api\/login"/);
});
it('weigert een body die geen JSON is, zonder de teller te verstoren', async () => {
const r = await ctx.haal('/api/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'geen json',
});
assert.equal(r.status, 400);
assert.equal((await ctx.login('geheim')).status, 200);
});
});
describe('createRequestListener: de uitzondering voor het inlogscherm', () => {
const ctx = serverMet({ credentials: GELDIG });
it('laat het inlogscherm zelf zien', async () => {
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH);
assert.equal(r.status, 200);
assert.match(r.headers.get('content-type'), /text\/html/);
assert.match(await r.text(), /login-form/);
});
it('laat ELK bestand door dat login.html noemt', async () => {
const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8');
const verwijzingen = [...html.matchAll(/(?:src|href)\s*=\s*"([^"]+)"/g)].map(m => m[1])
.filter(pad => !/^(https?:|data:|#|\/\/)/.test(pad));
assert.ok(verwijzingen.length >= 3, `verwacht minstens drie verwijzingen, gevonden ${verwijzingen.length}`);
for (const pad of verwijzingen) {
assert.equal((await ctx.haal(`/${pad}`)).status, 200, `/${pad} staat in login.html maar is dicht`);
}
});
it('laat de lettertypebestanden door waar die stylesheet naar wijst', async () => {
const css = readFileSync(new URL('../../client/vendor/inter/inter.css', import.meta.url), 'utf8');
const eerste = /url\(\.\/(files\/[^)]+)\)/.exec(css);
assert.ok(eerste, 'geen enkel woff2-bestand gevonden in inter.css');
assert.equal((await ctx.haal(`/vendor/inter/${eerste[1]}`)).status, 200);
});
it('houdt de app-schil dicht, en dat is de reden dat dit een eigen pagina is', async () => {
for (const pad of ['/index.html', '/app.js', '/boot-gate.js', '/shared/config.js',
'/portfolio.js', '/i18n/nl.js', '/data/_rpc.js']) {
const r = await ctx.haal(pad);
assert.equal(r.status, 302, `${pad} is bereikbaar zonder in te loggen`);
}
});
it('laat geen ander bestand uit de font-map van de app door dan de fonts zelf', async () => {
assert.equal((await ctx.haal('/vendor/crypto/noble-scure.js')).status, 302);
});
it('laat de uitzondering niet door een gecodeerd pad misbruiken', async () => {
for (const pad of ['/css/%2e%2e/app.js', '/vendor/inter/files/%2e%2e/%2e%2e/%2e%2e/app.js']) {
const r = await ctx.haal(pad);
assert.equal(r.status, 302, `${pad} kwam langs de uitzondering`);
}
});
});
describe('createRequestListener: de pogingenlimiet', () => {
const ctx = serverMet({ credentials: GELDIG });
it('gaat na een reeks missers wachten en meldt hoe lang', async () => {
let laatste;
for (let i = 0; i < CONFIG.AUTH.LOGIN_ATTEMPTS.FREE + 1; i++) laatste = await ctx.login('fout');
const json = await laatste.json();
assert.equal(laatste.status, 401);
assert.ok(json.retryAfterMs > 0, 'de wachttijd hoort in het antwoord te staan');
const geblokkeerd = await ctx.login('geheim');
assert.equal(geblokkeerd.status, 429);
assert.ok(Number(geblokkeerd.headers.get('retry-after')) >= 1);
});
});
describe('createRequestListener zonder inloggegevens: bewust open', () => {
const ctx = serverMet({});
it('serveert zonder cookie, want dit is de modus die resolveBinding op loopback houdt', async () => {
assert.equal((await ctx.haal('/api/health')).status, 200);
});
it('toont geen inlogscherm, want er valt niets te ontsluiten', async () => {
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH);
assert.equal(r.status, 302);
assert.equal(r.headers.get('location'), '/');
});
});
+11 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:0218bafc8f02c1e9ace72164cc21b81de083223f11fc50e42ec7fedf10c23058 // Generated, do not edit. sha256:90b8d22d967cee3d2817f9d1b730418df66b8dba385cbdf2b3f5d3091a0a67ef
import { describe, it, beforeEach, afterEach } from 'node:test'; import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
@@ -263,6 +263,16 @@ describe('createMarketClient: de reeks (fase 2)', () => {
await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer'); await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
}); });
it('de historie neemt de bron per rij mee als vierde element, met de naam per bronsleutel', async () => {
const { c } = client({ '/v1/series': () => json({
instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', kind: 'history',
columns: ['t', 'price', 'resolution', 'source'], sourceLabels: { bitvavo: 'Een Nederlandse aanbieder' },
rows: [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day', null]], next: null }) });
const uit = await c.history({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
assert.deepEqual(uit.points, [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day']]);
assert.deepEqual(uit.sourceLabels, { bitvavo: 'Een Nederlandse aanbieder' });
});
it('503 api.monthUnreachable op een reeks: later opnieuw, met de maand', async () => { it('503 api.monthUnreachable op een reeks: later opnieuw, met de maand', async () => {
const { c } = client({ '/v1/series': () => json({ error: 'x', key: 'api.monthUnreachable', month: '2026-09' }, 503) }); const { c } = client({ '/v1/series': () => json({ error: 'x', key: 'api.monthUnreachable', month: '2026-09' }, 503) });
const e = await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.monthUnreachable'); const e = await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.monthUnreachable');
+25 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:6129a8bbffc98b951f97ab835b37848de841b315ab86079ee5c45996f7fe3fe5 // Generated, do not edit. sha256:48e4432f919c88e3f0adaad52e46a88287459c06124c6a46c43d717bfbc8d8d8
import { describe, it, beforeEach, mock } from 'node:test'; import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -27,8 +27,10 @@ mock.module('../../client/data/_rpc.js', {
}, },
}); });
const { loadLicense, licenseFingerprint, licenseChangedSinceSeen, markLicenseSeen } = const { loadLicense, licenseFingerprint, licenseChangedSinceSeen, markLicenseSeen, counterText } =
await import('../../client/license.js'); await import('../../client/license.js');
const { setLocale } = await import('../../client/i18n/index.js');
const { CONFIG } = await import('../../shared/config.js');
const GRATIS = { const GRATIS = {
level: 'free', why: 'none', enforced: true, version: '1.0.0', installed: null, level: 'free', why: 'none', enforced: true, version: '1.0.0', installed: null,
@@ -94,3 +96,24 @@ describe('is er iets te melden', () => {
assert.equal(await licenseChangedSinceSeen(), false); assert.equal(await licenseChangedSinceSeen(), false);
}); });
}); });
describe('de teller naast de plus', () => {
it('noemt wat hij telt, voor elke grens van de gratis editie en in elke taal', async () => {
const max = Object.fromEntries(Object.keys(CONFIG.LICENSE.FREE.MAX).map(k => [k, 1]));
stand = { ...GRATIS, max };
await loadLicense();
try {
for (const taal of ['nl', 'en']) {
setLocale(taal);
for (const soort of Object.keys(max)) assert.match(counterText(soort, 0), /^\S.*: 0 \S+ 1$/, `${taal}, ${soort}`);
}
} finally { setLocale('nl'); }
assert.equal(counterText('assets', 1), 'Assets naast valuta in de gratis editie: 1 van 1');
});
it('valt weg zonder grens', async () => {
stand = BETAALD;
await loadLicense();
assert.equal(counterText('assets', 1), null);
});
});