- 新增 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 项全绿
681 lines
28 KiB
TypeScript
681 lines
28 KiB
TypeScript
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: '## a(60分)\n## b(60分)', 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: '## a(70分)\n## b(60分)', 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 Clamp(K6 回归)', () => {
|
||
it('TC-LIMIT-01: limit 超上限 clamp 到 500,limit=0 回落默认 50,offset 负数归零', 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 Auth(K7 回归)', () => {
|
||
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 guard(K2/§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+1(K2 回归)', 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 删除 409,force 成功', 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;
|
||
}
|
||
});
|
||
});
|