Files
work-hours-tracker/server/test/api.test.js
T
OpenClaw Agent 2329955e32 baseline: 薪酬逻辑收口到 shared/payroll.js 后的当前全量状态
这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。

此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。

当前状态(提交前已实测):
- index.html 内联薪酬算法已删除,改调 WHTPayroll
- shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用
- 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装

未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、
运行时 sqlite 数据与日志、时间戳备份、.trash。
2026-09-05 14:35:00 +08:00

277 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 服务端 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);