这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。 此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。 当前状态(提交前已实测): - index.html 内联薪酬算法已删除,改调 WHTPayroll - shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用 - 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装 未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、 运行时 sqlite 数据与日志、时间戳备份、.trash。
277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
// 服务端 API 自测:直接调 core/api.js,不起 HTTP 端口,跑得快也不占端口。
|
||
// 存储用临时 SQLite 文件,跑完删掉。
|
||
//
|
||
// node --disable-warning=ExperimentalWarning test/api.test.js
|
||
|
||
import { handleApi } from '../core/api.js';
|
||
import { SqliteStore } from '../adapters/sqlite-node.js';
|
||
import { mkdtempSync, rmSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
let pass = 0, fail = 0;
|
||
const tmp = mkdtempSync(join(tmpdir(), 'wht-test-'));
|
||
const store = new SqliteStore(join(tmp, 'test.db'));
|
||
|
||
function section(t) { console.log(`\n── ${t}`); }
|
||
function ok(name, cond, extra = '') {
|
||
if (cond) { pass++; console.log(` ✅ ${name}`); }
|
||
else { fail++; console.log(` ❌ ${name}${extra ? ' → ' + extra : ''}`); }
|
||
}
|
||
function eq(name, actual, expected) {
|
||
const a = JSON.stringify(actual), e = JSON.stringify(expected);
|
||
ok(name, a === e, `实得 ${a},应为 ${e}`);
|
||
}
|
||
|
||
let TOKEN = null;
|
||
async function call(method, path, body, token = TOKEN) {
|
||
const headers = { 'content-type': 'application/json' };
|
||
if (token) headers.authorization = `Bearer ${token}`;
|
||
const req = new Request('http://t' + path, {
|
||
method,
|
||
headers,
|
||
body: body === undefined ? undefined : JSON.stringify(body)
|
||
});
|
||
const res = await handleApi(req, store, {});
|
||
let json = null;
|
||
try { json = await res.json(); } catch {}
|
||
return { status: res.status, body: json };
|
||
}
|
||
|
||
const REC = (h, extra = {}) => ({
|
||
status: 'work', shift_type: 'day', hours: h,
|
||
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: '',
|
||
...extra
|
||
});
|
||
|
||
// ==========================================================
|
||
section('健康检查与注册策略');
|
||
{
|
||
let r = await call('GET', '/api/health');
|
||
eq('health 200', r.status, 200);
|
||
eq('初始 0 用户', r.body.users, 0);
|
||
|
||
r = await call('POST', '/api/register', { username: 'ab', password: 'longenough1' });
|
||
eq('用户名过短被拒', r.status, 400);
|
||
|
||
r = await call('POST', '/api/register', { username: 'kory', password: 'short' });
|
||
eq('密码过短被拒', r.status, 400);
|
||
|
||
r = await call('POST', '/api/register', { username: 'kory', password: 'kory-pass-123' });
|
||
eq('首个账号注册成功', r.status, 201);
|
||
|
||
r = await call('POST', '/api/register', { username: 'kory', password: 'kory-pass-123' });
|
||
eq('重名/注册已关闭', r.status, 403); // 已有用户且未开放注册 → 403 先命中
|
||
|
||
r = await call('GET', '/api/health');
|
||
eq('health 显示 1 用户', r.body.users, 1);
|
||
}
|
||
|
||
section('登录与鉴权');
|
||
{
|
||
let r = await call('POST', '/api/login', { username: 'kory', password: 'wrong-pass-x' }, null);
|
||
eq('错密码 401', r.status, 401);
|
||
|
||
r = await call('POST', '/api/login', { username: 'nobody', password: 'kory-pass-123' }, null);
|
||
eq('不存在的用户也是 401(不泄漏用户名)', r.status, 401);
|
||
|
||
r = await call('POST', '/api/login', { username: 'kory', password: 'kory-pass-123' }, null);
|
||
eq('登录成功', r.status, 200);
|
||
ok('返回 token', typeof r.body.token === 'string' && r.body.token.length === 64);
|
||
eq('初始 rev = 0', r.body.rev, 0);
|
||
TOKEN = r.body.token;
|
||
|
||
r = await call('GET', '/api/data', undefined, null);
|
||
eq('无 token 取数据 401', r.status, 401);
|
||
|
||
r = await call('GET', '/api/data', undefined, 'deadbeef');
|
||
eq('伪造 token 401', r.status, 401);
|
||
|
||
r = await call('GET', '/api/me');
|
||
eq('me 200', r.status, 200);
|
||
eq('me 用户名', r.body.user.username, 'kory');
|
||
}
|
||
|
||
section('推送与拉取');
|
||
{
|
||
let r = await call('POST', '/api/push', {
|
||
records: { '2026-09-01': REC(8), '2026-09-02': REC(8), '2026-09-03': REC(6, { contraband_found: 1 }) },
|
||
settings: { rate: 19.5, fullDays: 27 }
|
||
});
|
||
eq('push 200', r.status, 200);
|
||
eq('rev 递增到 1', r.body.rev, 1);
|
||
eq('applied = 4', r.body.applied, 4);
|
||
|
||
r = await call('GET', '/api/data');
|
||
eq('拉到 3 天', Object.keys(r.body.records).length, 3);
|
||
eq('9/3 工时 6h', r.body.records['2026-09-03'].hours, 6);
|
||
eq('9/3 查获 1 件', r.body.records['2026-09-03'].contraband_found, 1);
|
||
eq('设置 rate', r.body.settings.rate, 19.5);
|
||
|
||
// 增量:since=1 应该什么都没有
|
||
r = await call('GET', '/api/changes?since=1');
|
||
eq('since=1 无变更', Object.keys(r.body.records).length, 0);
|
||
eq('since=1 设置无变更', r.body.settings, null);
|
||
|
||
// 再改一天
|
||
r = await call('POST', '/api/push', { records: { '2026-09-04': REC(10, { contraband_missed: 2 }) } });
|
||
eq('rev 递增到 2', r.body.rev, 2);
|
||
|
||
r = await call('GET', '/api/changes?since=1');
|
||
eq('since=1 拿到 1 条新增', Object.keys(r.body.records).length, 1);
|
||
eq('新增的是 9/4', Object.keys(r.body.records)[0], '2026-09-04');
|
||
eq('changes 里 rev = 2', r.body.rev, 2);
|
||
}
|
||
|
||
section('设置是合并而非覆盖');
|
||
{
|
||
let r = await call('POST', '/api/push', { settings: { rate: 21 } });
|
||
eq('只推 rate', r.status, 200);
|
||
r = await call('GET', '/api/data');
|
||
eq('rate 已更新', r.body.settings.rate, 21);
|
||
eq('fullDays 未被清掉', r.body.settings.fullDays, 27);
|
||
}
|
||
|
||
section('删除用墓碑传播');
|
||
{
|
||
let r = await call('DELETE', '/api/record/2026-09-04');
|
||
eq('删除 200', r.status, 200);
|
||
const revAfterDelete = r.body.rev;
|
||
|
||
r = await call('GET', '/api/data');
|
||
ok('fullDump 里已看不到 9/4', r.body.records['2026-09-04'] === undefined);
|
||
eq('fullDump 剩 3 天', Object.keys(r.body.records).length, 3);
|
||
|
||
r = await call('GET', `/api/changes?since=${revAfterDelete - 1}`);
|
||
eq('增量里能看到 9/4 墓碑', r.body.records['2026-09-04'].deleted, 1);
|
||
|
||
r = await call('DELETE', '/api/record/2026-9-4');
|
||
eq('非法日期格式被拒', r.status, 400);
|
||
}
|
||
|
||
section('月度奖惩:draft 不需要金额,final 必须有');
|
||
{
|
||
let r = await call('POST', '/api/push', { monthAdjust: { '2026-09': { status: 'draft', note: '还没算完' } } });
|
||
eq('draft 无金额可存', r.status, 200);
|
||
|
||
r = await call('POST', '/api/push', { monthAdjust: { '2026-09': { status: 'final', note: '组长核定' } } });
|
||
eq('final 缺金额被拒', r.status, 400);
|
||
ok('错误信息点明原因', /终稿金额/.test(r.body.error), r.body.error);
|
||
|
||
r = await call('POST', '/api/push', {
|
||
monthAdjust: { '2026-09': { status: 'final', finalAmount: -55, note: '扣漏查', finalizedAt: '2026-10-01T09:00:00.000Z' } }
|
||
});
|
||
eq('final 带金额成功', r.status, 200);
|
||
|
||
r = await call('GET', '/api/data');
|
||
eq('终稿状态', r.body.adjust['2026-09'].status, 'final');
|
||
eq('终稿金额支持负数', r.body.adjust['2026-09'].final_amount, -55);
|
||
eq('定稿时间原样保留', r.body.adjust['2026-09'].finalized_at, '2026-10-01T09:00:00.000Z');
|
||
|
||
r = await call('POST', '/api/push', { monthAdjust: { '2026-13': { status: 'draft' } } });
|
||
eq('非法月份被拒', r.status, 400);
|
||
}
|
||
|
||
section('输入校验');
|
||
{
|
||
const bad = [
|
||
['status 非法', { records: { '2026-09-05': { status: 'sleep' } } }],
|
||
['shift 非法', { records: { '2026-09-05': { status: 'work', shift_type: 'noon', hours: 8 } } }],
|
||
['hours 非数字', { records: { '2026-09-05': REC('abc') } }],
|
||
['hours 超界', { records: { '2026-09-05': REC(99) } }],
|
||
['查获件数为小数', { records: { '2026-09-05': REC(8, { contraband_found: 1.5 }) } }],
|
||
['日期键格式错', { records: { '20260905': REC(8) } }],
|
||
['defaultShift 非法', { settings: { defaultShift: 'noon' } }]
|
||
];
|
||
for (const [name, body] of bad) {
|
||
const r = await call('POST', '/api/push', body);
|
||
ok(name + ' → 400', r.status === 400, `实得 ${r.status} ${r.body && r.body.error}`);
|
||
}
|
||
|
||
// 休息日必须把工时与奖惩清零,防脏数据参与结算
|
||
let r = await call('POST', '/api/push', {
|
||
records: { '2026-09-06': { status: 'rest', shift_type: 'day', hours: 8, contraband_found: 3 } }
|
||
});
|
||
eq('休息日可存', r.status, 200);
|
||
r = await call('GET', '/api/data');
|
||
eq('休息日工时归零', r.body.records['2026-09-06'].hours, 0);
|
||
eq('休息日查获归零', r.body.records['2026-09-06'].contraband_found, 0);
|
||
eq('休息日班次清空', r.body.records['2026-09-06'].shift_type, null);
|
||
}
|
||
|
||
section('导出格式与前端 bundle 对齐');
|
||
{
|
||
const r = await call('GET', '/api/export');
|
||
eq('export 200', r.status, 200);
|
||
eq('app 标识', r.body.app, 'work-hours-tracker');
|
||
eq('version 3', r.body.version, 3);
|
||
ok('records 是前端结构(有 penalty_reason 无 rev)',
|
||
r.body.records['2026-09-01'].penalty_reason !== undefined && r.body.records['2026-09-01'].rev === undefined);
|
||
ok('monthAdjust 用 finalAmount 驼峰', r.body.monthAdjust['2026-09'].finalAmount === -55);
|
||
}
|
||
|
||
section('多用户数据隔离');
|
||
{
|
||
// 开放注册开第二个号
|
||
const req = new Request('http://t/api/register', {
|
||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ username: 'other', password: 'other-pass-123' })
|
||
});
|
||
let res = await handleApi(req, store, { allowRegister: true });
|
||
eq('第二个账号注册成功', res.status, 201);
|
||
|
||
let r = await call('POST', '/api/login', { username: 'other', password: 'other-pass-123' }, null);
|
||
const otherToken = r.body.token;
|
||
eq('第二个账号 rev = 0', r.body.rev, 0);
|
||
|
||
r = await call('GET', '/api/data', undefined, otherToken);
|
||
eq('新账号看不到别人的记录', Object.keys(r.body.records).length, 0);
|
||
|
||
await call('POST', '/api/push', { records: { '2026-09-01': REC(12) } }, otherToken);
|
||
r = await call('GET', '/api/data', undefined, otherToken);
|
||
eq('新账号自己的 9/1 是 12h', r.body.records['2026-09-01'].hours, 12);
|
||
r = await call('GET', '/api/data');
|
||
eq('原账号 9/1 仍是 8h(未被串改)', r.body.records['2026-09-01'].hours, 8);
|
||
}
|
||
|
||
section('登出后 token 立即失效');
|
||
{
|
||
let r = await call('POST', '/api/logout');
|
||
eq('logout 200', r.status, 200);
|
||
r = await call('GET', '/api/data');
|
||
eq('登出后 401', r.status, 401);
|
||
}
|
||
|
||
section('会话过期');
|
||
{
|
||
const { hashToken } = await import('../core/auth.js');
|
||
const r0 = await call('POST', '/api/login', { username: 'kory', password: 'kory-pass-123' }, null);
|
||
const tk = r0.body.token;
|
||
// 手动把这条会话改成已过期
|
||
store.db.prepare('UPDATE sessions SET expires_at = ? WHERE token_hash = ?')
|
||
.run('2000-01-01T00:00:00.000Z', await hashToken(tk));
|
||
const r = await call('GET', '/api/data', undefined, tk);
|
||
eq('过期 token 401', r.status, 401);
|
||
ok('提示需重新登录', /过期|失效/.test(r.body.error), r.body.error);
|
||
}
|
||
|
||
section('404 与畸形请求体');
|
||
{
|
||
let r = await call('GET', '/api/nope');
|
||
eq('未知接口 404', r.status, 404);
|
||
|
||
const req = new Request('http://t/api/login', {
|
||
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{not json'
|
||
});
|
||
const res = await handleApi(req, store, {});
|
||
eq('畸形 JSON 400', res.status, 400);
|
||
}
|
||
|
||
// ==========================================================
|
||
store.close();
|
||
rmSync(tmp, { recursive: true, force: true });
|
||
console.log(`\n${'='.repeat(46)}`);
|
||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||
console.log('='.repeat(46));
|
||
process.exit(fail === 0 ? 0 : 1);
|