137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { history, overview, totals, type Holding, type Rate } from '../src/calculation';
|
|
import { positionInput, date, amount } from '../src/validation';
|
|
import { validateBackup } from '../src/backup';
|
|
import { RatesService } from '../src/rates';
|
|
import { Database } from '../src/database';
|
|
const rev = (amount: string, day: string) => ({
|
|
id: randomUUID(),
|
|
amount,
|
|
effectiveDate: new Date(day),
|
|
notes: '',
|
|
reason: 'balance',
|
|
});
|
|
const p = (side = 'asset', currency = 'CNY'): Holding => ({
|
|
id: randomUUID(),
|
|
name: 'test',
|
|
kind: 'account',
|
|
side,
|
|
currency,
|
|
revisions: [rev('100.1', '2026-09-01')],
|
|
});
|
|
const rate = (value: string, day: string): Rate => ({
|
|
currency: 'USD',
|
|
baseCurrency: 'CNY',
|
|
date: new Date(day),
|
|
rate: value,
|
|
source: 'manual',
|
|
});
|
|
test('decimal totals and liability sign', () => {
|
|
const a = p(),
|
|
b = p('liability');
|
|
b.revisions[0].amount = '0.2';
|
|
assert.equal(totals([a, b], [], 'CNY', '2026-09-01').net, '99.90');
|
|
});
|
|
test('missing FX explicitly incomplete', () => {
|
|
const v = totals([p('asset', 'USD')], [], 'CNY', '2026-09-01');
|
|
assert.equal(v.complete, false);
|
|
assert.deepEqual(v.missing, ['USD']);
|
|
assert.equal(v.items[0].converted, null);
|
|
});
|
|
test('correction recalculates later delta', () => {
|
|
const a = p();
|
|
a.revisions = [rev('90.1', '2026-09-01'), rev('110.1', '2026-09-02')];
|
|
assert.equal(history(a)[1].delta, '20');
|
|
assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '110.10');
|
|
});
|
|
test('FX and actual changes separated', () => {
|
|
const a = p('asset', 'USD');
|
|
a.revisions = [rev('100', '2026-09-01'), rev('110', '2026-09-02')];
|
|
const o = overview([a], [rate('7', '2026-09-01'), rate('8', '2026-09-02')], 'CNY', '2026-09-02');
|
|
assert.equal(o.trend[1].fxChange, '100.00');
|
|
assert.equal(o.trend[1].balanceChange, '80.00');
|
|
assert.equal(o.net, '880.00');
|
|
});
|
|
test('large monetary strings stay exact', () => {
|
|
const a = p();
|
|
a.revisions = [rev('9999999999999999.98765432', '2026-09-01')];
|
|
assert.equal(history(a)[0].after, '9999999999999999.98765432');
|
|
assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '9999999999999999.99');
|
|
});
|
|
test('reject invalid dates, negative values and credit card assets', () => {
|
|
assert.equal(date.safeParse('2026-02-30').success, false);
|
|
assert.equal(amount.safeParse('-1').success, false);
|
|
assert.equal(
|
|
positionInput.safeParse({
|
|
name: 'Card',
|
|
kind: 'account',
|
|
side: 'asset',
|
|
category: 'credit_card',
|
|
currency: 'CNY',
|
|
amount: '1',
|
|
date: '2026-09-01',
|
|
}).success,
|
|
false,
|
|
);
|
|
});
|
|
test('backup rejects auth data and broken relations', () => {
|
|
const b = {
|
|
format: 'worthpath',
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
baseCurrency: 'CNY',
|
|
currencies: ['CNY'],
|
|
positions: [],
|
|
rates: [],
|
|
links: [],
|
|
};
|
|
assert.doesNotThrow(() => validateBackup(b));
|
|
assert.throws(() => validateBackup({ ...b, passwordHash: 'forbidden' }));
|
|
assert.throws(() =>
|
|
validateBackup({ ...b, links: [{ sourceId: randomUUID(), targetId: randomUUID() }] }),
|
|
);
|
|
});
|
|
test('public FX uses a fixed request, preserves decimal tokens, manual rates and failure fallback', async () => {
|
|
const writes: any[] = [];
|
|
let manual = false;
|
|
const db = {
|
|
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
|
|
position: { findMany: async () => [{ currency: 'USD' }] },
|
|
$transaction: async (fn: any) =>
|
|
fn({
|
|
exchangeRate: {
|
|
findUnique: async () => (manual ? { source: 'manual' } : null),
|
|
upsert: async (v: any) => writes.push(v.create),
|
|
},
|
|
}),
|
|
} as unknown as Database;
|
|
const fx = new RatesService(db),
|
|
original = globalThis.fetch;
|
|
try {
|
|
globalThis.fetch = async (url) => {
|
|
assert.equal(
|
|
String(url),
|
|
'https://api.frankfurter.dev/v2/rates?base=USD"es=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD',
|
|
);
|
|
return new Response(
|
|
'[{"base":"USD","quote":"CNY","date":"2026-09-01","rate":7.987654321098}]',
|
|
);
|
|
};
|
|
await fx.refresh('test-owner');
|
|
assert.equal(writes[0].rate, '7.987654321098');
|
|
manual = true;
|
|
await fx.refresh('test-owner');
|
|
assert.equal(writes.length, 1);
|
|
globalThis.fetch = async () => {
|
|
throw Error('offline');
|
|
};
|
|
await assert.rejects(() => fx.refresh('test-owner'), /原币金额和已有汇率已保留/);
|
|
assert.equal(writes.length, 1);
|
|
assert.equal(fx.status('test-owner').state, 'error');
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|