Files
WorthPath/apps/api/test/transfers.test.ts
T

293 lines
10 KiB
TypeScript

import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
import { normalizeIcon } from '../src/icons';
import sharp from 'sharp';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('transfers are atomic, scoped, retry-safe, decimal exact and included in backups', async () => {
const db = new PrismaClient(),
names: string[] = [];
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
const r = await fetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
...(data ? { 'Content-Type': 'application/json' } : {}),
},
body: data ? JSON.stringify(data) : undefined,
});
return {
status: r.status,
data: r.headers.get('content-type')?.includes('application/zip')
? ((await readBackupZip(Buffer.from(await r.arrayBuffer()))) as any)
: await r.json(),
cookie: r.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function account() {
const username = 'wp_transfer_' + randomUUID(),
password = randomBytes(18).toString('hex');
names.push(username);
const r = await call('/auth/register', '', 'POST', { username, password });
assert.equal(r.status, 201);
return { ...r, id: (await db.user.findUniqueOrThrow({ where: { username } })).id, password };
}
async function position(
cookie: string,
name: string,
amount = '100',
currency = 'CNY',
side = 'asset',
) {
const r = await call('/positions', cookie, 'POST', {
name,
kind: 'account',
category: side === 'asset' ? 'bank' : 'credit_card',
side,
currency,
amount,
date: '2026-09-01T10:35',
});
assert.equal(r.status, 201);
return r.data.id as string;
}
try {
const a = await account(),
b = await account();
const sourceId = await position(a.cookie, '转出账户'),
targetId = await position(a.cookie, '转入账户', '10'),
other = await position(b.cookie, '他人账户');
const request = {
sourceId,
targetId,
amount: '25',
received: '25',
fee: '1',
date: '2026-09-02T10:35',
notes: '转账验收',
requestId: randomUUID(),
};
assert.equal((await call('/transfers', '', 'POST', request)).status, 401);
assert.equal(
(await call('/transfers', a.cookie, 'POST', { ...request, targetId: other })).status,
400,
);
assert.equal(
(await call('/transfers', a.cookie, 'POST', { ...request, sourceId: targetId })).status,
400,
);
assert.equal(
(await call('/transfers', a.cookie, 'POST', { ...request, userId: b.id })).status,
400,
);
assert.equal(
(await call('/transfers', a.cookie, 'POST', { ...request, amount: '100', received: '100' }))
.status,
400,
);
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 0);
assert.equal(await db.revision.count({ where: { positionId: sourceId } }), 1);
assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201);
assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201);
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 1);
assert.equal(
(await call('/transfers', a.cookie, 'POST', { ...request, amount: '24', received: '24' }))
.status,
409,
);
const rows = (await call('/positions', a.cookie)).data;
assert.equal(rows.find((p: any) => p.id === sourceId).amount, '74');
assert.equal(rows.find((p: any) => p.id === targetId).amount, '35');
assert.equal((await call('/overview', a.cookie)).data.net, '109.00');
assert.equal((await call('/transfers', b.cookie)).data.length, 0);
const revision = rows.find((p: any) => p.id === sourceId).history.at(-1);
assert.equal(
(
await call(`/positions/${sourceId}/revisions/${revision.id}`, a.cookie, 'PUT', {
amount: '70',
date: request.date,
})
).status,
409,
);
assert.equal(
(
await call(`/positions/${sourceId}/revisions`, a.cookie, 'POST', {
amount: '70',
date: request.date,
reason: 'transfer_out',
})
).status,
400,
);
assert.equal(
(
await call(`/positions/${sourceId}/revisions`, a.cookie, 'POST', {
amount: '80',
date: '2026-09-01T12:00',
reason: 'balance',
})
).status,
409,
);
assert.equal((await call('/settings', a.cookie, 'PATCH', { showNotes: 'false' })).status, 400);
const debt = await position(a.cookie, '信用卡', '10', 'CNY', 'liability');
assert.equal(
(
await call('/transfers', a.cookie, 'POST', {
...request,
requestId: randomUUID(),
targetId: debt,
})
).status,
400,
);
assert.equal(
(
await call('/transfers', a.cookie, 'POST', {
...request,
requestId: randomUUID(),
date: '2026-09-01T10:34',
})
).status,
409,
);
const usd = await position(a.cookie, '美元账户', '20', 'USD');
assert.equal(
(
await call('/transfers', a.cookie, 'POST', {
...request,
requestId: randomUUID(),
sourceId: usd,
amount: '1.00000001',
received: '7.10000001',
fee: '0',
date: '2026-09-02T11:00',
})
).status,
201,
);
assert.equal(
(await call('/positions', a.cookie)).data.find((p: any) => p.id === usd).amount,
'18.99999999',
);
const race = await Promise.all(
[1, 2].map(() =>
call('/transfers', a.cookie, 'POST', {
...request,
requestId: randomUUID(),
sourceId: usd,
amount: '15',
received: '100',
fee: '0',
date: '2026-09-03T10:00',
}),
),
);
assert.equal(race.filter((r) => r.status === 201).length, 1);
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1);
assert.equal(
(
await call('/settings', a.cookie, 'PATCH', {
hiddenMenus: ['asset', 'debt'],
showNotes: false,
idleMinutes: 15,
})
).status,
200,
);
assert.deepEqual((await call('/settings', a.cookie)).data.hiddenMenus, ['asset', 'debt']);
assert.equal((await call('/settings', a.cookie)).data.showNotes, false);
assert.equal(
(await call('/settings', a.cookie, 'PATCH', { hiddenMenus: ['settings'] })).status,
400,
);
assert.equal((await call('/settings', a.cookie, 'PATCH', { showSidebar: false })).status, 400);
const backup = (await call('/backup', a.cookie)).data;
assert.equal(backup.transfers.length, 3);
assert.equal(backup.preferences.showNotes, false);
assert.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']);
const broken = structuredClone(backup);
broken.transfers[0].amount = '999';
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status,
400,
);
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
const restored = (await call('/backup', b.cookie)).data;
assert.equal(restored.transfers.length, 3);
// Appending into an existing space preserves its display preferences.
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
const c = await account();
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal((await call('/settings', c.cookie)).data.showNotes, false);
for (const t of restored.transfers) {
assert.ok(
restored.positions
.find((p: any) => p.id === t.sourceId)
?.revisions.some((r: any) => r.id === t.sourceRevisionId),
);
assert.ok(
restored.positions
.find((p: any) => p.id === t.targetId)
?.revisions.some((r: any) => r.id === t.targetRevisionId),
);
}
await call('/positions/' + sourceId, a.cookie, 'PATCH', {
name: '转出账户',
category: 'bank',
hidden: true,
});
const visibleTransfers = (await call('/transfers', a.cookie)).data;
assert.equal(visibleTransfers.length, 2);
assert.ok(
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),
);
assert.equal(
(await call('/transfers', a.cookie, 'POST', { ...request, requestId: randomUUID() })).status,
400,
);
} finally {
await db.user.deleteMany({ where: { username: { in: names } } });
await db.$disconnect();
}
});
test('icon processing creates transparent white cutouts and preserves brand colour', async () => {
const pixels = Buffer.from([255, 255, 255, 255, 10, 120, 60, 255]);
const input = await sharp(pixels, { raw: { width: 2, height: 1, channels: 4 } })
.png()
.toBuffer();
const output = await sharp(await normalizeIcon(input))
.ensureAlpha()
.raw()
.toBuffer();
assert.equal(output[3], 0);
assert.deepEqual([...output.subarray(4)], [10, 120, 60, 255]);
});
test('OpenAPI exposes authenticated routes and actual transfer request fields', async () => {
const r = await fetch(base + '/openapi.json');
assert.equal(r.status, 200);
const doc = await r.json();
assert.ok(doc.paths['/api/transfers']);
assert.ok(
doc.paths['/api/transfers'].post.requestBody.content['application/json'].schema.properties
.requestId,
);
assert.equal(doc.components.securitySchemes.session.in, 'cookie');
assert.doesNotMatch(JSON.stringify(doc), /passwordHash|DATABASE_URL|ghp_/);
const html = await fetch(base + '/docs');
assert.equal(html.status, 200);
assert.match(await html.text(), /swagger-ui/);
});