feat: persist account group display order and backup preferences

This commit is contained in:
陈煜 committed 2026-10-03 12:29:35 +08:00
1 parent 20643b366f
commit 1af5edbadc
28 files changed
+379 -47

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('group order persists per user, validates input and survives ZIP and legacy imports', async () => {
const db = new PrismaClient(),
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
async function user() {
const u = await db.user.create({
data: { username: 'wp_groups_' + randomUUID(), passwordHash: 'unused' },
});
ids.push(u.id);
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId: u.id,
expiresAt: new Date(Date.now() + 3600000),
},
});
return { id: u.id, cookie: 'wp_session=' + token };
}
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: origin,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, data: await res.json() };
}
try {
const a = await user(),
b = await user(),
c = await user();
assert.deepEqual((await call('/auth/me', a.cookie)).data.accountGroupOrder, []);
const order = ['理财', '', '未分组', '日常'];
assert.equal(
(await call('/settings', a.cookie, 'PATCH', { accountGroupOrder: order })).status,
200,
);
assert.deepEqual(
(await db.user.findUniqueOrThrow({ where: { id: a.id } })).accountGroupOrder,
order,
);
assert.deepEqual((await call('/auth/me', a.cookie)).data.accountGroupOrder, order);
assert.deepEqual((await call('/settings', a.cookie)).data.accountGroupOrder, order);
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, []);
for (const invalid of [['日常', '日常'], [' 日常 ', '日常'], [42], ['x'.repeat(61)]]) {
assert.equal(
(await call('/settings', a.cookie, 'PATCH', { accountGroupOrder: invalid })).status,
400,
);
}
assert.deepEqual((await call('/settings', a.cookie)).data.accountGroupOrder, order);
assert.equal(
(
await call('/positions', a.cookie, 'POST', {
name: 'Daily',
kind: 'account',
side: 'asset',
category: 'cash',
groupName: '日常',
currency: 'CNY',
amount: '20',
date: '2026-09-01T10:00',
})
).status,
201,
);
const zip = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(zip.status, 200);
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
assert.deepEqual(backup.preferences.accountGroupOrder, order);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order);
await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] });
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
409,
);
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']);
delete backup.preferences.accountGroupOrder;
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []);
const comments: any[] = await db.$queryRawUnsafe(
"SELECT COLUMN_COMMENT AS comment FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'User' AND COLUMN_NAME = 'accountGroupOrder'",
);
assert.ok(comments[0].comment.includes('分组'));
} finally {
await db.user.deleteMany({ where: { id: { in: ids } } });
await db.$disconnect();
}
});
+20 -10
View File
@@ -120,7 +120,7 @@ test('negative balances are confined to accounts; group names and debt principal
category: 'bank',
kind: 'account',
side: 'asset',
amount: '-12.12345678',
amount: '-12.24681357',
currency: 'CNY',
date,
groupName: '日常',
@@ -159,9 +159,9 @@ test('trend starts at the first balance, retains valid zero days and handles an
assert.deepEqual(trend([p], [], 'CNY', '2026-09-01', '2026-09-14'), []);
});
test('debt signs and currency symbols preserve the original decimal strings', () => {
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.12345678' } as Position;
assert.equal(positionAmount(p), '-9999999999999999.12345678');
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.12345678');
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.24681357' } as Position;
assert.equal(positionAmount(p), '-9999999999999999.24681357');
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.24681357');
assert.equal(positionAmount({ ...p, amount: '0.00000000' }), '0.00000000');
assert.equal(positionAmount({ ...p, side: 'asset' }), p.amount);
});
@@ -348,14 +348,14 @@ test('liability account credit balances invert display and input without losing
const card = {
kind: 'account',
side: 'liability',
amount: '-9999999999999999.12345678',
amount: '-9999999999999999.24681357',
} as Position;
assert.equal(positionAmount(card), '9999999999999999.12345678');
assert.equal(positionAmount(card, '12.12345678'), '-12.12345678');
assert.equal(positionAmount(card), '9999999999999999.24681357');
assert.equal(positionAmount(card, '12.24681357'), '-12.24681357');
assert.equal(positionAmount(card, '-0.00000000'), '0.00000000');
assert.equal(accountInputAmount(card, '12.12345678'), '-12.12345678');
assert.equal(accountInputAmount(card, '-12.12345678'), '12.12345678');
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.12345678'), '12.12345678');
assert.equal(accountInputAmount(card, '12.24681357'), '-12.24681357');
assert.equal(accountInputAmount(card, '-12.24681357'), '12.24681357');
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.24681357'), '12.24681357');
});
test('credit balances count as assets across zero, retain FX attribution and do not offset other liabilities', () => {
const card: Holding = {
@@ -422,3 +422,13 @@ test('credit balances count as assets across zero, retain FX attribution and do
assert.equal(cashflowDelta('liability', 'balance', '100', '-25').toFixed(), '125');
assert.equal(cashflowDelta('liability', 'balance', '-25', '10').toFixed(), '-35');
});
test('group order keeps visible subsets stable and distinguishes unnamed and named groups', async () => {
const { orderedGroups, mergeGroupOrder } = await import('../../web/src/group-order');
assert.deepEqual(
orderedGroups(['日常', '', '未分组', '理财', '日常'], ['理财', '', '日常', '已隐藏']),
['理财', '', '日常', '未分组'],
);
assert.deepEqual(orderedGroups(['日常'], ['理财', '', '日常']), ['日常']);
assert.deepEqual(mergeGroupOrder(['日常', '理财'], ['理财', '隐藏']), ['日常', '理财', '隐藏']);
});
+9
View File
@@ -190,3 +190,12 @@ test('ZIP v7 restores groups and schedules while v6 remains readable', async ()
contents['manifest.json'] = JSON.stringify(manifest);
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
});
test('ZIP settings retain group order while missing legacy order remains optional', async () => {
const b = empty();
b.preferences!.accountGroupOrder = ['理财', '', '未分组', '日常'];
const restored = validateBackup(await readBackupZip(await archive(packBackup(b))));
assert.deepEqual(restored.preferences!.accountGroupOrder, b.preferences!.accountGroupOrder);
const legacy = validateBackup(await readBackupZip(await archive(packBackup(empty()))));
assert.equal(legacy.preferences!.accountGroupOrder, undefined);
});