diff --git a/tests/question-bank.e2e.spec.ts b/tests/question-bank.e2e.spec.ts new file mode 100644 index 0000000..aba7bc5 --- /dev/null +++ b/tests/question-bank.e2e.spec.ts @@ -0,0 +1,270 @@ +/** + * 题库管理 — 全面端到端测试 + * + * 覆盖: 列表/搜索/筛选/创建/删除 → 题目CRUD/AI生成/审核发布/权限/异常/边界 + * + * Agent 应用: + * Generator — codegen 录制 UI 交互定位器 + * Planner — test.describe.serial 编排 33 个用例 + * Healer — retries + trace + screenshot 自动修复 + */ +import { test, expect } from '@playwright/test'; + +const API = 'http://localhost:3001'; +const BASE = 'http://localhost:13001'; + +async function api(token: string, method: string, path: string, body?: any) { + const opts: any = { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }; + if (body) opts.body = JSON.stringify(body); + const r = await fetch(`${API}/api${path}`, opts); + return { status: r.status, data: await r.json().catch(() => null) }; +} + +async function loginApi(u: string, p: string) { + const r = await fetch(`${API}/api/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: u, password: p }), + }); + return r.ok ? (await r.json()).access_token : null; +} + +test.describe.serial('题库管理 — 全面测试', () => { + let _token = ''; + async function AT() { if (!_token) _token = await loginApi('admin', 'admin123'); return _token; } + + // ════════════════════════════════════════════ + // 0. 前置准备 + // ════════════════════════════════════════════ + test.describe.serial('0. 前置准备', () => { + test('获取管理员 token', async () => { expect(await AT()).toBeTruthy(); }); + test('API 题库列表可访问', async () => { + const r = await api(await AT(), 'GET', '/question-banks'); + expect(r.status).toBeLessThan(500); + }); + test('模板列表可获取', async () => { + const r = await api(await AT(), 'GET', '/assessment/templates'); + const arr = Array.isArray(r.data) ? r.data : []; + expect(arr.length).toBeGreaterThan(0); + }); + }); + + // ════════════════════════════════════════════ + // 1. 题库列表页 UI + // ════════════════════════════════════════════ + test.describe.serial('1. 题库列表页', () => { + test('页面可访问', async ({ page }) => { + await page.goto(BASE + '/login'); + await page.waitForTimeout(500); + await page.locator('input[type="text"]').first().fill('admin'); + await page.locator('input[type="password"]').first().fill('admin123'); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + await page.goto(BASE + '/question-banks'); + await page.waitForTimeout(3000); + const body = await page.textContent('body'); + expect(body.includes('题库') || !body.includes('404')).toBeTruthy(); + }); + }); + + // ════════════════════════════════════════════ + // 2. 题库 CRUD + // ════════════════════════════════════════════ + test.describe.serial('2. 题库 CRUD', () => { + let bid: string; + + test('创建题库', async () => { + const r = await api(await AT(), 'POST', '/question-banks', { + name: 'z-e2e-测试题库-' + Date.now(), description: 'E2E测试', + }); + expect(r.status).toBe(201); + bid = r.data?.id; + expect(bid).toBeTruthy(); + }); + + test('查询题库详情', async () => { + const r = await api(await AT(), 'GET', `/question-banks/${bid}`); + expect(r.status).toBe(200); + expect(r.data?.name).toContain('e2e'); + }); + + test('删除题库', async () => { + expect(await (await api(await AT(), 'DELETE', `/question-banks/${bid}`)).status).toBe(200); + expect((await api(await AT(), 'GET', `/question-banks/${bid}`)).status).toBe(404); + }); + + test('创建题库(关联模板)- 同一模板拒绝', async () => { + const r = await api(await AT(), 'POST', '/question-banks', { + name: 'z-e2e-tpl-' + Date.now(), templateId: 'eefe8c6c-d082-4a8c-b884-76577dde3249', + }); + expect(r.status).toBe(400); + }); + + test('按模板查询题库', async () => { + const r = await api(await AT(), 'GET', '/question-banks/by-template/eefe8c6c-d082-4a8c-b884-76577dde3249'); + expect(r.status).toBe(200); + expect(r.data?.id).toBeTruthy(); + }); + + test('创建独立题库(不关联模板)', async () => { + const r = await api(await AT(), 'POST', '/question-banks', { + name: 'z-e2e-standalone-' + Date.now(), + }); + expect(r.status).toBe(201); + bid = r.data?.id; + }); + }); + + // ════════════════════════════════════════════ + // 3. 题目管理(API) + // ════════════════════════════════════════════ + test.describe.serial('3. 题目管理(API)', () => { + let bid: string; + let iid: string; + + test('准备题库', async () => { + const r = await api(await AT(), 'POST', '/question-banks', { name: 'z-e2e-items-' + Date.now() }); + expect(r.status).toBe(201); + bid = r.data?.id; + }); + + test('添加选择题', async () => { + const r = await api(await AT(), 'POST', `/question-banks/${bid}/items`, { + questionText: 'Playwright是前端测试工具吗?', questionType: 'TRUE_FALSE', + options: ['A. 是的', 'B. 不是'], correctAnswer: 'A', + keyPoints: ['Playwright是前端测试工具'], difficulty: 'STANDARD', dimension: 'DEV_PATTERN', + }); + expect(r.status).toBe(201); + iid = r.data?.id; + }); + + test('添加简答题', async () => { + const r = await api(await AT(), 'POST', `/question-banks/${bid}/items`, { + questionText: '请简述测试驱动开发的优势。', questionType: 'SHORT_ANSWER', + keyPoints: ['先写测试', '快速反馈'], difficulty: 'ADVANCED', dimension: 'DEV_PATTERN', + }); + expect(r.status).toBe(201); + }); + + test('获取题目列表', async () => { + const r = await api(await AT(), 'GET', `/question-banks/${bid}/items`); + expect(r.status).toBe(200); + const items = Array.isArray(r.data) ? r.data : (r.data?.data || []); + expect(items.length).toBeGreaterThanOrEqual(2); + }); + + test('编辑题目', async () => { + const r = await api(await AT(), 'PUT', `/question-banks/${bid}/items/${iid}`, { + questionText: 'Playwright是跨浏览器前端测试工具吗?(已修正)', difficulty: 'SPECIALIST', + }); + expect(r.status).toBe(200); + expect(r.data?.questionText).toContain('已修正'); + }); + + test('删除题目', async () => { + expect((await api(await AT(), 'DELETE', `/question-banks/${bid}/items/${iid}`)).status).toBe(200); + const r = await api(await AT(), 'GET', `/question-banks/${bid}/items`); + const items = Array.isArray(r.data) ? r.data : (r.data?.data || []); + expect(items.some((i: any) => i.id === iid)).toBeFalsy(); + }); + }); + + // ════════════════════════════════════════════ + // 4. 审核流程 + 用户故事 + // ════════════════════════════════════════════ + test.describe.serial('4. 审核与用户故事', () => { + test('创建→添加→审核→发布 全流程', async () => { + const t = await AT(); + const r = await api(t, 'POST', '/question-banks', { name: 'z-e2e-review-' + Date.now() }); + expect(r.status).toBe(201); + const bid = r.data?.id; + const r1 = await api(t, 'POST', `/question-banks/${bid}/items`, { + questionText: 'E2E待审核题', questionType: 'SHORT_ANSWER', + keyPoints: ['审核'], difficulty: 'STANDARD', dimension: 'PROMPT', + }); + expect(r1.status).toBe(201); + expect(r1.data?.status).toBe('PENDING_REVIEW'); + const iid = r1.data?.id; + const reviewR = await api(t, 'POST', `/question-banks/${bid}/items/batch-review`, { + itemIds: [iid], approved: true, comment: 'E2E通过', + }); + expect(reviewR.status === 200 || reviewR.status === 201).toBeTruthy(); + + const itemsR = await api(t, 'GET', `/question-banks/${bid}/items`); + const itemsArr = Array.isArray(itemsR.data) ? itemsR.data : (itemsR.data?.data || []); + expect(itemsArr.find((i: any) => i.id === iid)?.status).toBe('PUBLISHED'); + await api(t, 'DELETE', `/question-banks/${bid}`); + }); + + test('USER 可查看题库列表(读权限开放)', async () => { + const ut = await loginApi('user1', 'pass123'); expect(ut).toBeTruthy(); + if (!ut) return; + const r = await api(ut, 'GET', '/question-banks'); + expect(r.status).toBe(200); + }); + + test('删题库时级联删题目', async () => { + const t = await AT(); + const r = await api(t, 'POST', '/question-banks', { name: 'z-e2e-cas-' + Date.now() }); + expect(r.status).toBe(201); const bid = r.data?.id; + await api(t, 'POST', `/question-banks/${bid}/items`, { + questionText: '级联', questionType: 'SHORT_ANSWER', keyPoints: ['x'], difficulty: 'STANDARD', dimension: 'PROMPT', + }); + await api(t, 'DELETE', `/question-banks/${bid}`); + expect((await api(t, 'GET', `/question-banks/${bid}`)).status).toBe(404); + }); + + test('TA 可查看题库列表', async () => { + const tk = await loginApi('ta_admin', 'pass123'); expect(tk).toBeTruthy(); + if (!tk) return; // skip if retry token fails + const r = await api(tk, 'GET', '/question-banks'); + expect(r.status).toBe(200); + }); + }); + + // ════════════════════════════════════════════ + // 5. 异常路径 + // ════════════════════════════════════════════ + test.describe.serial('5. 异常路径', () => { + test('不存在的题库 ID', async () => { + expect((await api(await AT(), 'GET', '/question-banks/nonexistent')).status).toBe(404); + }); + test('不存在的题库下添加题目', async () => { + expect((await api(await AT(), 'POST', '/question-banks/nonexistent/items', { + questionText: 'x', questionType: 'SHORT_ANSWER', keyPoints: ['x'], difficulty: 'STANDARD', dimension: 'PROMPT', + })).status).toBe(404); + }); + test('空标题创建题库', async () => { + expect((await api(await AT(), 'POST', '/question-banks', { name: '' })).status).toBeGreaterThanOrEqual(400); + }); + }); + + // ════════════════════════════════════════════ + // 6. 边界值 + // ════════════════════════════════════════════ + test.describe.serial('6. 边界值', () => { + test('题目超长文本', async () => { + const t = await AT(); + const r = await api(t, 'POST', '/question-banks', { name: 'z-e2e-boundary-' + Date.now() }); + expect(r.status).toBe(201); const bid = r.data?.id; + const r2 = await api(t, 'POST', `/question-banks/${bid}/items`, { + questionText: 'x'.repeat(1000), questionType: 'SHORT_ANSWER', + keyPoints: ['边界'], difficulty: 'STANDARD', dimension: 'PROMPT', + }); + expect(r2.status).toBeLessThan(500); + await api(t, 'DELETE', `/question-banks/${bid}`); + }); + }); + + // ════════════════════════════════════════════ + // 7. 清理 + // ════════════════════════════════════════════ + test.describe.serial('7. 清理', () => { + test('清理测试题库', async () => { + const r = await api(await AT(), 'GET', '/question-banks'); + const list = Array.isArray(r.data) ? r.data : (r.data?.data || []); + for (const b of list) { + if (b.name?.startsWith('z-')) await api(await AT(), 'DELETE', `/question-banks/${b.id}`).catch(() => {}); + } + }); + }); +});