Files
L2keka/server/src/__tests__/api.test.ts
T
hangshuo652 6a2419d4a4 L2考核落地:人才测评整合为L2考核track,新增查重初筛与e2e验证
- 新增 L2考核 track(7维标准/100分):选题难度赋分cap、功能完整性地板线、合格判定
- 人才测评整合进 L2考核:移除 L2/L3 两级认定与 question_id 机制
- 新增 l2-topics 选题元数据服务与 config/l2-topics.json(11命题题+自选题)
- 新增查重初筛 plagiarism-detect(MD5精确比对+归一化相似度+提交行为,仅告警)
- 修复 L2 维度 DIM_FILE_FILTERS 缺失导致 AI 协作记录证据漏喂
- e2e:修复 Windows spawn、DB 隔离,L2 用例 27 项全绿
2026-08-23 20:58:56 +08:00

681 lines
28 KiB
TypeScript
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.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import crypto from 'crypto';
import os from 'os';
import path from 'path';
import type { Server } from 'http';
// 关闭 DNS 解析校验(同 feature-review,避免测试依赖真实网络)
process.env.SSRF_DNS_CHECK = 'off';
// 使用独立临时库,避免污染/耦合真实 data/ai-review.db
process.env.DB_PATH = path.join(os.tmpdir(), `ai-review-api-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
let server: Server;
let token = '';
let projectId = '';
let standardId = '';
let entryId = '';
function headers(extra: Record<string, string> = {}): Record<string, string> {
return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extra };
}
const BASE = 'http://localhost:18902';
async function api(method: string, path: string, body?: any): Promise<{ status: number; data: any }> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: headers(),
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data: any;
try { data = JSON.parse(text); } catch { data = text; }
return { status: res.status, data };
}
beforeAll(async () => {
// Import app after env is configured
process.env.ADMIN_TEST_TOKEN = 'true'; // 启用 force-review 测试端点
const mod = await import('../index');
const app = mod.app;
const { config } = await import('../config');
server = app.listen(18902);
// Wait for server ready
await fetch(`${BASE}/api/health`);
});
afterAll(async () => {
server?.close();
});
describe('Auth API', () => {
it('TC-AUTH-06: should reject without token', async () => {
const { status, data } = await api('GET', '/api/projects');
expect(status).toBe(401);
expect(data.error).toBe('未登录');
});
it('TC-AUTH-07: should reject invalid token', async () => {
token = 'xxx-invalid';
const { status, data } = await api('GET', '/api/projects');
expect(status).toBe(401);
expect(data.error).toBe('登录已过期');
token = '';
});
it('TC-AUTH-02: should reject wrong password', async () => {
const { status, data } = await api('POST', '/api/auth/login', { password: 'wrong' });
expect(status).toBe(401);
expect(data.error).toBe('密码错误');
});
it('TC-AUTH-01: should login with correct password', async () => {
const { config } = await import('../config');
const { status, data } = await api('POST', '/api/auth/login', { password: config.authPassword });
expect(status).toBe(200);
expect(data.token).toBeTruthy();
token = data.token;
});
it('TC-AUTH-10: /api/health should work without auth', async () => {
const oldToken = token;
token = '';
const { status, data } = await api('GET', '/api/health');
expect(status).toBe(200);
expect(data.status).toBe('ok');
token = oldToken;
});
it('TC-AUTH-08: should access API with valid token', async () => {
const { status } = await api('GET', '/api/projects');
expect(status).toBe(200);
});
});
describe('Projects API', () => {
it('TC-PROJ-05: should list empty projects', async () => {
const { status, data } = await api('GET', '/api/projects');
expect(status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});
it('TC-PROJ-02: should reject empty name', async () => {
const { status, data } = await api('POST', '/api/projects', { name: '' });
expect(status).toBe(400);
expect(data.error).toBe('项目名称为必填项');
});
it('TC-PROJ-02b: should reject missing track', async () => {
const { status, data } = await api('POST', '/api/projects', { name: '无赛道项目' });
expect(status).toBe(400);
expect(data.error).toContain('赛道');
});
it('TC-PROJ-01: should create project', async () => {
const { status, data } = await api('POST', '/api/projects', { name: '测试项目', description: '集成测试', track: '赛道二' });
expect(status).toBe(200);
expect(data.id).toBeTruthy();
expect(data.name).toBe('测试项目');
projectId = data.id;
});
it('TC-PROJ-03: should trim name', async () => {
const { status, data } = await api('POST', '/api/projects', { name: ' 空格项目 ', track: '赛道二' });
expect(status).toBe(200);
expect(data.name).toBe('空格项目');
});
it('TC-PROJ-04: should list projects with stats', async () => {
const { status, data } = await api('GET', '/api/projects');
expect(status).toBe(200);
expect(data.length).toBeGreaterThanOrEqual(2);
expect(data[0].total).toBeDefined();
expect(data[0].reviewed).toBeDefined();
});
it('TC-PROJ-07: should 404 for non-existent project', async () => {
const { status, data } = await api('GET', '/api/projects/non-existent');
expect(status).toBe(404);
expect(data.error).toBe('项目不存在');
});
it('TC-PROJ-06: should get project detail', async () => {
const { status, data } = await api('GET', `/api/projects/${projectId}`);
expect(status).toBe(200);
expect(data.id).toBe(projectId);
expect(data.total).toBeDefined();
expect(data.standards).toBeDefined();
});
it('TC-PROJ-08: should update project', async () => {
const { status, data } = await api('PUT', `/api/projects/${projectId}`, { name: '更新后项目名' });
expect(status).toBe(200);
expect(data.name).toBe('更新后项目名');
});
it('TC-PROJ-09: should partially update project', async () => {
const { status, data } = await api('PUT', `/api/projects/${projectId}`, { description: '仅改描述' });
expect(status).toBe(200);
expect(data.description).toBe('仅改描述');
});
});
describe('Standards API', () => {
const validContent = '## 代码质量(30分)\n代码整洁度\n## 架构设计(40分)\n模块化程度\n## 测试覆盖(30分)\n自动化测试';
it('TC-STD-02: should reject invalid format', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/standards`, {
name: '无效格式', content: '普通文本',
});
expect(status).toBe(400);
expect(data.error).toContain('格式异常');
});
it('TC-STD-03: should reject total > max', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/standards`, {
name: '超分', content: '## 维度一(100分)\nx\n## 维度二(80分)\nx',
});
expect(status).toBe(400);
expect(data.error).toContain('超过');
});
it('TC-STD-04: should reject empty name', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/standards`, {
name: '', content: validContent,
});
expect(status).toBe(400);
expect(data.error).toBe('标准名称为必填项');
});
it('TC-STD-01: should create standard', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/standards`, {
name: 'L2标准', content: validContent, category_tag: '算法',
});
expect(status).toBe(200);
expect(data.id).toBeTruthy();
expect(data.dimensions).toHaveLength(3);
expect(data.category_tag).toBe('算法');
standardId = data.id;
});
it('TC-STD-MAX: should create standard with custom max_score', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/standards`, {
name: '限120分标准', content: '## a60分)\n## b60分)', max_score: '120',
});
expect(status).toBe(200);
expect(data.max_score).toBe(120);
// Cleanup
await api('DELETE', `/api/projects/${projectId}/standards/${data.id}`);
});
it('TC-STD-MAX-OVER: should reject total over custom max_score', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/standards`, {
name: '超自定义上限', content: '## a70分)\n## b60分)', max_score: '100',
});
expect(status).toBe(400);
expect(data.error).toContain('超过');
});
it('TC-STD-06: should create standard with category_tag', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/standards`, {
name: '默认标准', content: validContent,
});
expect(status).toBe(200);
expect(data.category_tag).toBe('');
});
it('TC-STD-07: should list standards', async () => {
const { status, data } = await api('GET', `/api/projects/${projectId}/standards`);
expect(status).toBe(200);
expect(data.length).toBeGreaterThanOrEqual(2);
expect(data[0].dimensions).toBeDefined();
});
it('TC-STD-10: should update standard', async () => {
const { status, data } = await api('PUT', `/api/projects/${projectId}/standards/${standardId}`, {
name: '更新后标准',
});
expect(status).toBe(200);
expect(data.name).toBe('更新后标准');
});
});
describe('Entries API', () => {
// 外部 file:// 路径:被启动评审时克隆秒失败(不触发真实网络),保持测试确定性
const repoUrl = `file://C:\\test-repo-${Date.now()}`;
it('TC-ENT-04: should fallback to default standard when category tag does not match', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/entries`, {
title: '使用默认标准', repo_url: 'https://example.com/none.git', category_tag: '不存在',
});
expect(status).toBe(200);
expect(data.standard_snapshot).toBeTruthy();
});
it('TC-ENT-02: should reject empty title', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/entries`, {
repo_url: repoUrl,
});
expect(status).toBe(400);
expect(data.error).toBe('标题为必填项');
});
it('TC-ENT-03: should reject empty repo_url', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/entries`, {
title: '无仓库',
});
expect(status).toBe(400);
expect(data.error).toBe('仓库地址为必填项');
});
it('TC-ENT-01: should create entry', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/entries`, {
title: '选手A', repo_url: repoUrl, difficulty: '★★★',
participant: '张三', category_tag: '算法',
});
expect(status).toBe(200);
expect(data.id).toBeTruthy();
expect(data.pass_line).toBe(60);
expect(data.standard_snapshot).toBeTruthy();
expect(data.status).toBe('pending');
entryId = data.id;
});
it('TC-ENT-20: should create entry with base_branch', async () => {
const url = `https://example.com/base-branch-${Date.now()}.git`;
const { status, data } = await api('POST', `/api/projects/${projectId}/entries`, {
title: '赛道二选手', repo_url: url, base_branch: 'main',
});
expect(status).toBe(200);
expect(data.base_branch).toBe('main');
});
it('TC-ENT-21: base_branch defaults to empty', async () => {
const url = `https://example.com/no-branch-${Date.now()}.git`;
const { status, data } = await api('POST', `/api/projects/${projectId}/entries`, {
title: '无分支', repo_url: url,
});
expect(status).toBe(200);
expect(data.base_branch).toBe('');
});
it('TC-ENT-06: pass line calculation', async () => {
const url = `https://example.com/pass-line-${Date.now()}.git`;
const { data } = await api('POST', `/api/projects/${projectId}/entries`, {
title: '通过线验证', repo_url: url,
});
// standard total = 30+40+30 = 100, pass_line = 100 * 60% = 60
expect(data.pass_line).toBe(60);
});
it('TC-ENT-07: should list entries', async () => {
const { status, data } = await api('GET', `/api/projects/${projectId}/entries`);
expect(status).toBe(200);
expect(data.items.length).toBeGreaterThan(0);
expect(data.total).toBeGreaterThan(0);
});
it('TC-ENT-09: should get entry detail', async () => {
const { status, data } = await api('GET', `/api/projects/${projectId}/entries/${entryId}`);
expect(status).toBe(200);
expect(data.id).toBe(entryId);
expect(data.dimensions).toBeDefined();
// 赛道必选后条目使用项目对应赛道的默认标准(赛道二模板 8 维),不再是无赛道时的 3 维 fallback
expect(data.dimensions.length).toBe(8);
expect(data.standard_snapshot).toBeTruthy();
});
it('TC-ENT-10: should 404 non-existent entry', async () => {
const { status, data } = await api('GET', `/api/projects/${projectId}/entries/non-existent`);
expect(status).toBe(404);
expect(data.error).toBe('条目不存在');
});
it('TC-ENT-11: should update pending entry', async () => {
const { status, data } = await api('PUT', `/api/projects/${projectId}/entries/${entryId}`, {
title: '更新后选手A',
});
expect(status).toBe(200);
expect(data.title).toBe('更新后选手A');
});
it('TC-ENT-22: should update base_branch', async () => {
const { status, data } = await api('PUT', `/api/projects/${projectId}/entries/${entryId}`, {
base_branch: 'develop',
});
expect(status).toBe(200);
expect(data.base_branch).toBe('develop');
});
it('TC-ENT-23: should clear base_branch', async () => {
const { status, data } = await api('PUT', `/api/projects/${projectId}/entries/${entryId}`, {
base_branch: '',
});
expect(status).toBe(200);
expect(data.base_branch).toBe('');
});
it('TC-ENT-15: should batch import', async () => {
const entries = [
{ title: '批量A', repo_url: `https://batch-a-${Date.now()}.git` },
{ title: '批量B', repo_url: `https://batch-b-${Date.now()}.git` },
];
const { status, data } = await api('POST', `/api/projects/${projectId}/entries/batch`, { entries });
expect(status).toBe(200);
expect(data.imported).toBe(2);
expect(data.items).toHaveLength(2);
});
it('TC-ENT-16: should report batch errors', async () => {
const entries = [
{ title: '', repo_url: 'https://x.git' },
{ title: '好项', repo_url: `https://good-${Date.now()}.git` },
];
const { data } = await api('POST', `/api/projects/${projectId}/entries/batch`, { entries });
expect(data.imported).toBe(1);
expect(data.errors).toHaveLength(1);
expect(data.errors[0].reason).toContain('标题为空');
});
it('TC-ENT-17: should reject empty batch array', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/entries/batch`, { entries: [] });
expect(status).toBe(400);
expect(data.error).toBe('请提供条目列表');
});
it('TC-ENT-18: should start review', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/entries/${entryId}/start`);
expect(status).toBe(200);
expect(data.success).toBe(true);
});
it('TC-ENT-19: should not start already started entry', async () => {
const { status, data } = await api('POST', `/api/projects/${projectId}/entries/${entryId}/start`);
expect(status).toBe(409);
expect(data.error).toContain('不允许启动');
});
});
describe('Summary API', () => {
it('TC-PROJ-13: should return summary', async () => {
const { status, data } = await api('GET', `/api/projects/${projectId}/summary`);
expect(status).toBe(200);
expect(data.project).toBeDefined();
expect(data.totalEntries).toBeDefined();
expect(data.categories).toBeDefined();
});
});
describe('Pagination Limit ClampK6 回归)', () => {
it('TC-LIMIT-01: limit 超上限 clamp 到 500limit=0 回落默认 50offset 负数归零', async () => {
for (let i = 0; i < 2; i++) {
const r = await api('POST', `/api/projects/${projectId}/entries`, { title: `lim-${i}`, repo_url: `file://C:\\lim-${i}-${Date.now()}` });
expect(r.status).toBe(200);
}
const big = await api('GET', `/api/projects/${projectId}/entries?limit=100000`);
expect(big.status).toBe(200);
expect(big.data.limit).toBe(500);
const neg = await api('GET', `/api/projects/${projectId}/entries?offset=-5&limit=0`);
expect(neg.status).toBe(200);
expect(neg.data.offset).toBe(0);
expect(neg.data.limit).toBe(50);
});
});
describe('Cookie AuthK7 回归)', () => {
it('TC-AUTH-11: 登录写入 httpOnly cookie/auth/me 与业务接口可用,logout 清除 cookie', async () => {
const { config } = await import('../config');
const loginRes = await fetch(`${BASE}/api/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: config.authPassword }),
});
expect(loginRes.status).toBe(200);
const setCookie = loginRes.headers.get('set-cookie') || '';
expect(setCookie).toContain('token=');
expect(setCookie.toLowerCase()).toContain('httponly');
const cookie = setCookie.split(';')[0];
const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } });
expect(meRes.status).toBe(200);
expect((await meRes.json()).role).toBe('admin');
const projRes = await fetch(`${BASE}/api/projects`, { headers: { Cookie: cookie } });
expect(projRes.status).toBe(200);
const logoutRes = await fetch(`${BASE}/api/auth/logout`, { method: 'POST', headers: { Cookie: cookie } });
expect(logoutRes.status).toBe(200);
expect((logoutRes.headers.get('set-cookie') || '').toLowerCase()).toMatch(/token=;/i);
});
});
describe('Admin Correction: cap + L2 final_level(整合后回归)', () => {
let pid = ''; let eid = '';
beforeAll(async () => {
const proj = await api('POST', '/api/projects', { name: 'report-cap-test', track: 'L2考核' });
pid = proj.data.id;
const list = await api('GET', `/api/projects/${pid}/standards`);
for (const s of list.data) { await api('DELETE', `/api/projects/${pid}/standards/${s.id}`); }
// L2 7维标准:功能30+设计10+测试10+AI协作15+技术选型15+代码+README10+业务场景10=100
await api('POST', `/api/projects/${pid}/standards`, {
name: 'rc-std', category_tag: 'L2考核',
content: '## 功能完整性(30分)\n## 设计文档(10分)\n## 测试用例与测试结果(10分)\n## AI协作过程记录(15分)\n## 技术选型与范式运用(15分)\n## 代码质量+README(10分)\n## 业务场景理解与需求分析(10分)',
});
const entry = await api('POST', `/api/projects/${pid}/entries`, {
title: 'cap-entry', repo_url: `file://C:\\cap-${Date.now()}`, selected_topic: 'self',
});
eid = entry.data.id;
});
afterAll(async () => { await api('DELETE', `/api/projects/${pid}?force=true`); });
it('TC-REPORT-01: L2 人工修正后 final_level 按合格/不合格重算(含功能完整性地板线)', async () => {
const full = [
{ name: '功能完整性', score: 30, maxScore: 30 },
{ name: '设计文档', score: 10, maxScore: 10 },
{ name: '测试用例与测试结果', score: 10, maxScore: 10 },
{ name: 'AI协作过程记录', score: 15, maxScore: 15 },
{ name: '技术选型与范式运用', score: 15, maxScore: 15 },
{ name: '代码质量+README', score: 10, maxScore: 10 },
{ name: '业务场景理解与需求分析', score: 10, maxScore: 10 },
];
const fr = await api('PUT', `/api/projects/${pid}/entries/${eid}/force-review`, { dimensions: full });
expect(fr.status).toBe(200);
const seed = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: full });
expect(seed.status).toBe(200);
let detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
expect(detail.data.final_level).toBe('合格'); // 总分100 ≥60 且 功能30 ≥15
// 功能完整性低于地板线15 → 即使总分高也判不合格
const lowFunc = full.map(d => d.name === '功能完整性' ? { ...d, score: 10 } : d);
const fix = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: lowFunc });
expect(fix.status).toBe(200);
detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
expect(detail.data.final_level).toBe('不合格'); // 功能10 < 15 地板线
// 恢复
const fix2 = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: full });
expect(fix2.status).toBe(200);
detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
expect(detail.data.final_level).toBe('合格');
});
it('TC-REPORT-02: 人工修正应用 max_score_cap 后再扣迟交', async () => {
const db = (await import('../db')).default;
db.prepare('UPDATE entries SET max_score_cap = 30, late_days = 0 WHERE id = ?').run(eid);
const full = [
{ name: '功能完整性', score: 30, maxScore: 30 },
{ name: '设计文档', score: 10, maxScore: 10 },
{ name: '测试用例与测试结果', score: 10, maxScore: 10 },
{ name: 'AI协作过程记录', score: 15, maxScore: 15 },
{ name: '技术选型与范式运用', score: 15, maxScore: 15 },
{ name: '代码质量+README', score: 10, maxScore: 10 },
{ name: '业务场景理解与需求分析', score: 10, maxScore: 10 },
];
const fix = await api('PUT', `/api/projects/${pid}/entries/${eid}/report`, { dimensions: full });
expect(fix.status).toBe(200);
const detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
expect(detail.data.raw_score).toBe(100);
expect(detail.data.final_score).toBe(30); // min(100, cap30) - 0
db.prepare('UPDATE entries SET max_score_cap = 100 WHERE id = ?').run(eid);
});
});
describe('Retry attempt + deliverables + delete guardK2/§5.1 回归)', () => {
let pid = ''; let eid = '';
beforeAll(async () => {
const proj = await api('POST', '/api/projects', { name: `misc-${Date.now()}`, track: '赛道一' });
pid = proj.data.id;
await api('POST', `/api/projects/${pid}/standards`, { name: 'm-std', content: '## 场景价值(8分)\n## 架构设计(5分)' });
const entry = await api('POST', `/api/projects/${pid}/entries`, { title: 'm-entry', repo_url: `file://C:\\misc-${Date.now()}` });
eid = entry.data.id;
});
it('TC-ENT-RETRY: retry 使 attempt+1K2 回归)', async () => {
const db = (await import('../db')).default;
db.prepare("UPDATE entries SET status = 'failed', attempt = 1 WHERE id = ?").run(eid);
const r = await api('POST', `/api/projects/${pid}/entries/${eid}/retry`);
expect(r.status).toBe(200);
const detail = await api('GET', `/api/projects/${pid}/entries/${eid}`);
expect(detail.data.attempt).toBe(2);
expect(['queued', 'cloning', 'clone_fail']).toContain(detail.data.status);
});
it('TC-DELIV-01: 成果物初始化/汇总/CSV 导出', async () => {
const init = await api('PUT', `/api/projects/${pid}/entries/deliverables/init`);
expect(init.status).toBe(200);
expect(init.data.initialized).toBe(1);
const sum = await api('GET', `/api/projects/${pid}/entries/deliverables/summary`);
expect(sum.status).toBe(200);
expect(sum.data.summary.length).toBe(7);
expect(sum.data.totalRequired).toBe(6);
const res = await fetch(`${BASE}/api/projects/${pid}/entries/deliverables/export`, { headers: headers() });
expect(res.status).toBe(200);
const buf = await res.arrayBuffer();
const bytes = new Uint8Array(buf);
expect(bytes[0]).toBe(0xEF); // UTF-8 BOM(服务端发送正确;fetch.text() 会剥 BOM 故查原始字节)
expect(bytes[1]).toBe(0xBB);
expect(bytes[2]).toBe(0xBF);
const text = new TextDecoder().decode(buf);
expect(text).toContain('源代码');
expect(text).toContain('演示录屏');
});
it('TC-PROJ-DEL: 含未完成条目时非 force 删除 409force 成功', async () => {
const r = await api('DELETE', `/api/projects/${pid}`);
expect(r.status).toBe(409);
const f = await api('DELETE', `/api/projects/${pid}?force=true`);
expect(f.status).toBe(200);
});
});
describe('Cancel Review(§3.2', () => {
let pid = ''; let eid = '';
beforeAll(async () => {
const proj = await api('POST', '/api/projects', { name: `cancel-${Date.now()}`, track: '赛道一' });
pid = proj.data.id;
await api('POST', `/api/projects/${pid}/standards`, { name: 'c-std', content: '## 场景价值(8分)' });
const e = await api('POST', `/api/projects/${pid}/entries`, { title: 'c-entry', repo_url: `file://C:\\c-${Date.now()}` });
eid = e.data.id;
});
afterAll(async () => { await api('DELETE', `/api/projects/${pid}?force=true`); });
it('TC-CANCEL-01: queued 状态可取消 → cancelled', async () => {
const db = (await import('../db')).default;
db.prepare("UPDATE entries SET status = 'queued' WHERE id = ?").run(eid);
const r = await api('POST', `/api/projects/${pid}/entries/${eid}/cancel`);
expect(r.status).toBe(200);
const d = await api('GET', `/api/projects/${pid}/entries/${eid}`);
expect(d.data.status).toBe('cancelled');
});
it('TC-CANCEL-02: pending 状态不可取消 → 409', async () => {
const db = (await import('../db')).default;
db.prepare("UPDATE entries SET status = 'pending' WHERE id = ?").run(eid);
const r = await api('POST', `/api/projects/${pid}/entries/${eid}/cancel`);
expect(r.status).toBe(409);
});
});
describe('赛道一子类型标准匹配(§3.3.2)', () => {
it('TC-SUBTYPE-01: sub_type=新規 命中对应标准;无 sub_type 回落默认标准', async () => {
const proj = await api('POST', '/api/projects', { name: `sub-${Date.now()}`, track: '赛道一' });
const pid = proj.data.id;
const list = await api('GET', `/api/projects/${pid}/standards`);
for (const s of list.data) await api('DELETE', `/api/projects/${pid}/standards/${s.id}`);
await api('POST', `/api/projects/${pid}/standards`, { name: '新規标准', category_tag: '新規', content: '## 新規维度(5分)' });
await api('POST', `/api/projects/${pid}/standards`, { name: '修正标准', category_tag: '修正', content: '## 修正维度(5分)' });
await api('POST', `/api/projects/${pid}/standards`, { name: '默认标准', category_tag: '', content: '## 默认维度(5分)' });
const e1 = await api('POST', `/api/projects/${pid}/entries`, { title: 'sub-new', repo_url: `file://C:\\sn-${Date.now()}`, sub_type: '新規' });
const snap1 = JSON.parse(e1.data.standard_snapshot);
expect(snap1[0].name).toBe('新規维度');
const e2 = await api('POST', `/api/projects/${pid}/entries`, { title: 'sub-def', repo_url: `file://C:\\sd-${Date.now()}` });
const snap2 = JSON.parse(e2.data.standard_snapshot);
expect(snap2[0].name).toBe('默认维度');
await api('DELETE', `/api/projects/${pid}?force=true`);
});
});
describe('Cleanup', () => {
it('should delete project with force', async () => {
const { status } = await api('DELETE', `/api/projects/${projectId}?force=true`);
expect(status).toBe(200);
});
});
describe('Password Change API', () => {
it('TC-PWD-01: should reject without token', async () => {
const saved = token;
token = '';
const { status } = await api('POST', '/api/auth/password', { currentPassword: 'x', newPassword: 'abcdef' });
token = saved;
expect(status).toBe(401);
});
it('TC-PWD-02: should reject wrong current password', async () => {
const { status, data } = await api('POST', '/api/auth/password', { currentPassword: 'wrong-current', newPassword: 'abcdef123' });
expect(status).toBe(403);
expect(data.error).toContain('当前密码错误');
});
it('TC-PWD-03: should reject short new password', async () => {
const { config } = await import('../config');
const { status, data } = await api('POST', '/api/auth/password', { currentPassword: config.authPassword, newPassword: '123' });
expect(status).toBe(400);
expect(data.error).toContain('至少 6 位');
});
it('TC-PWD-04: should change password, enforce on login, and restore', async () => {
const { config } = await import('../config');
const original = config.authPassword;
const newPwd = 'new-pass-12345';
try {
const changeRes = await api('POST', '/api/auth/password', { currentPassword: original, newPassword: newPwd });
expect(changeRes.status).toBe(200);
expect(changeRes.data.success).toBe(true);
const oldLogin = await api('POST', '/api/auth/login', { password: original });
expect(oldLogin.status).toBe(401);
const newLogin = await api('POST', '/api/auth/login', { password: newPwd });
expect(newLogin.status).toBe(200);
expect(newLogin.data.token).toBeTruthy();
token = newLogin.data.token; // 改密轮换了 AUTH_SECRET,旧 token 已失效
} finally {
const restoreRes = await api('POST', '/api/auth/password', { currentPassword: newPwd, newPassword: original });
expect(restoreRes.status).toBe(200);
const finalLogin = await api('POST', '/api/auth/login', { password: original });
expect(finalLogin.status).toBe(200);
token = finalLogin.data.token;
}
});
});