/// /** * A6-QA-01 · 任务中心端到端用例(真实 API + 真实 worker + 真实浏览器) * * 覆盖: * ① 提交演练任务(混合模式)→ 列表出现 → 详情 SSE 推进到「部分失败」→ 仅重试失败项; * ② 顶栏任务角标显示进行中数量,面板可进入任务详情; * ③ 提交后立即取消 → 状态「已取消」,未开始项「已跳过」; * ④ 同一 Idempotency-Key + 同一 body 返回同一 taskId,不同 body 40902(API 断言); * ⑤ SSE Last-Event-ID 断点续推:重连首帧 seq 更大、不重发历史; * ⑥ 权限三态:仅 material:read 不渲染写操作;无 material:read → /403。 * * 门控:仅 A6_E2E=1 时执行;默认 skip,不影响 smoke / CI。 * 前置:API 3000、Web 5173、worker(QUEUE=all 或 verify)与真实 PG / Redis,已 `pnpm db:init`。 * 数据自建自清:临时账号 qa6.tasks.e2e / qa6.tasks.ro / qa6.tasks.none + 两个临时角色; * 结束后删除临时任务(级联任务项 / 事件)、幂等键、outbox 事件、账号与角色;审计记录保留(预期留痕)。 * * 运行:A6_E2E=1 pnpm exec playwright test e2e/a6-tasks.spec.ts --reporter=list */ import { randomBytes } from 'node:crypto'; import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test'; import { PrismaClient } from '@prisma/client'; const API_BASE = process.env.A6_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1'; const WEB_BASE = process.env.A6_E2E_BASE_URL ?? 'http://127.0.0.1:5173'; const ENABLED = process.env.A6_E2E === '1'; const PASSWORD = 'lingxi2026'; /** 与 a1 / a3 / a4 / a5 共用(明文 lingxi2026) */ const PASSWORD_HASH = '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; const ADMIN_ACCOUNT = 'qa6.tasks.e2e'; const ADMIN_NAME = 'A6 E2E 超管'; const READONLY_ACCOUNT = 'qa6.tasks.ro'; const READONLY_NAME = 'A6 E2E 只读'; const NONE_ACCOUNT = 'qa6.tasks.none'; const NONE_NAME = 'A6 E2E 无权限'; const READONLY_ROLE_CODE = 'a6e2e_task_viewer'; const READONLY_ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZA6E1'; const NONE_ROLE_CODE = 'a6e2e_no_material'; const NONE_ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZA6E2'; const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; const prisma = new PrismaClient(); let apiContext: APIRequestContext | null = null; let adminSession: QaSession | null = null; let readonlySession: QaSession | null = null; let noneSession: QaSession | null = null; let customerId = ''; let adminUserId = ''; let readonlyUserId = ''; let noneUserId = ''; const createdTaskIds: string[] = []; interface QaSession { accessToken: string; refreshToken: string; user: { id: string; name: string; account: string }; permissions: string[]; mustChangePassword: boolean; } interface TaskDetail { id: string; status: string; itemFailed: number; itemSkipped: number; } test.skip(!ENABLED, 'A6_E2E=1 才运行任务中心 e2e(依赖 API :3000 + worker + web :5173 + PG)'); test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } }); // --------------------------------------------------------------------------- // 基础工具 // --------------------------------------------------------------------------- function ulid(): string { let time = Date.now(); const timeChars = new Array(10); for (let index = 9; index >= 0; index -= 1) { timeChars[index] = ENCODING[time % 32] ?? '0'; time = Math.floor(time / 32); } const random = randomBytes(16); return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function requireRecord(value: unknown, label: string): Record { if (!isRecord(value)) throw new Error(`${label} 不是 JSON 对象`); return value; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } function requireApi(): APIRequestContext { if (apiContext === null) throw new Error('APIRequestContext 未初始化'); return apiContext; } function requireAdminSession(): QaSession { if (adminSession === null) throw new Error('超管会话未就绪'); return adminSession; } function requireReadonlySession(): QaSession { if (readonlySession === null) throw new Error('只读会话未就绪'); return readonlySession; } function requireNoneSession(): QaSession { if (noneSession === null) throw new Error('无权限会话未就绪'); return noneSession; } async function injectSession(page: Page, session: QaSession): Promise { const persisted = { accessToken: session.accessToken, refreshToken: session.refreshToken, user: session.user, permissions: session.permissions, mustChangePassword: session.mustChangePassword, }; await page.addInitScript((value: unknown) => { window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value)); }, persisted); } async function loginViaApi(account: string): Promise { const context = requireApi(); const login = await context.post(`${API_BASE}/auth/login`, { data: { account, password: PASSWORD }, }); expect(login.status(), `POST /auth/login ${account}`).toBe(200); const rawLogin = (await login.text()) || '{}'; const body = requireRecord(JSON.parse(rawLogin) as unknown, '登录响应'); if (typeof body.accessToken !== 'string' || body.accessToken === '') { throw new Error( `登录响应缺少 accessToken:HTTP ${String(login.status())} body=${rawLogin.slice(0, 300)}`, ); } const accessToken = body.accessToken; const refreshToken = String(body.refreshToken); const meResponse = await context.get(`${API_BASE}/me`, { headers: { authorization: `Bearer ${accessToken}` }, }); expect(meResponse.status(), `GET /me ${account}`).toBe(200); const rawMe = (await meResponse.text()) || '{}'; const me = requireRecord(JSON.parse(rawMe) as unknown, '/me 响应'); const user = requireRecord(me.user, '/me.user'); return { accessToken, refreshToken, user: { id: String(user.id), name: String(user.name), account: String(user.account), }, permissions: stringArray(me.permissions), mustChangePassword: me.mustChangePassword === true, }; } async function createRole( code: string, id: string, name: string, permissions: string[], ): Promise { await prisma.role.deleteMany({ where: { code } }); await prisma.role.create({ data: { id, code, name, description: 'A6 e2e 临时角色', isSystem: false, status: 'ACTIVE', permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) }, dataScopes: { create: [{ id: ulid(), scopeType: 'all' }] }, }, }); } async function createUser( account: string, name: string, roleCodes: readonly string[], ): Promise { const [department, roles] = await Promise.all([ prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }), prisma.role.findMany({ where: { code: { in: [...roleCodes] } }, select: { id: true } }), ]); await prisma.user.deleteMany({ where: { account } }); const userId = ulid(); await prisma.user.create({ data: { id: userId, name, account, email: `${account}@lingxi.cn`, phone: `139${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`, passwordHash: PASSWORD_HASH, departmentId: department.id, mustChangePassword: false, roles: { create: roles.map((role) => ({ roleId: role.id })) }, }, }); return userId; } async function createTaskViaApi( token: string, input: { itemCount: number; mode: 'success' | 'mixed' | 'fail' }, idempotencyKey: string, ): Promise<{ status: number; taskId: string | null }> { const response = await fetch(`${API_BASE}/media-tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ type: 'demo', customerId, itemCount: input.itemCount, mode: input.mode, }), }); const body: unknown = await response.json().catch(() => null); const taskId = isRecord(body) && typeof body.taskId === 'string' ? body.taskId : null; return { status: response.status, taskId }; } async function fetchTaskDetail(token: string, taskId: string): Promise { const response = await fetch(`${API_BASE}/media-tasks/${taskId}`, { headers: { Authorization: `Bearer ${token}` }, }); expect(response.ok, `GET /media-tasks/${taskId}`).toBe(true); const rawDetail = (await response.text()) || '{}'; const body = requireRecord(JSON.parse(rawDetail) as unknown, '任务详情'); return { id: String(body.id), status: String(body.status), itemFailed: typeof body.itemFailed === 'number' ? body.itemFailed : 0, itemSkipped: typeof body.itemSkipped === 'number' ? body.itemSkipped : 0, }; } /** 读取 SSE 首个事件帧的 id(用于 Last-Event-ID 续推验证),读完即断开 */ async function readSseFirstEventId( taskId: string, token: string, lastEventId?: string, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => { controller.abort(); }, 20_000); try { const query = lastEventId === undefined ? '' : `?last_event_id=${encodeURIComponent(lastEventId)}`; const response = await fetch(`${API_BASE}/media-tasks/${taskId}/events${query}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' }, signal: controller.signal, }); expect(response.ok, 'SSE 响应').toBe(true); const reader = response.body?.getReader(); if (reader === undefined) throw new Error('SSE 响应无 body'); const decoder = new TextDecoder(); let buffer = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const match = /^id:\s*(\d+)$/m.exec(buffer); if (match?.[1] !== undefined) { await reader.cancel().catch(() => undefined); return match[1]; } } throw new Error('SSE 未收到带 id 的事件'); } finally { clearTimeout(timer); controller.abort(); } } async function waitToast(page: Page, text: RegExp): Promise { await expect(page.locator('[role="alert"]').filter({ hasText: text }).first()).toBeVisible({ timeout: 15_000, }); } // --------------------------------------------------------------------------- // 全局编排 // --------------------------------------------------------------------------- test.beforeAll(async () => { if (!ENABLED) return; apiContext = await request.newContext(); try { const web = await apiContext.get(`${WEB_BASE}/`); expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200); const customer = await prisma.customer.findFirst({ where: { status: 'ACTIVE' }, select: { id: true }, orderBy: { createdAt: 'asc' }, }); if (customer === null) throw new Error('缺少可提交任务的启用客户,请先执行 seed'); customerId = customer.id; // 清理上次异常中断的遗留(按临时账号名 / 角色 code) await prisma.mediaTask.deleteMany({ where: { createdByName: { in: [ADMIN_NAME] } } }); await createRole(READONLY_ROLE_CODE, READONLY_ROLE_ID, 'A6 端到端任务只读', ['material:read']); await createRole(NONE_ROLE_CODE, NONE_ROLE_ID, 'A6 端到端无任务权限', ['delivery:read']); adminUserId = await createUser(ADMIN_ACCOUNT, ADMIN_NAME, ['super_admin']); readonlyUserId = await createUser(READONLY_ACCOUNT, READONLY_NAME, [READONLY_ROLE_CODE]); noneUserId = await createUser(NONE_ACCOUNT, NONE_NAME, [NONE_ROLE_CODE]); adminSession = await loginViaApi(ADMIN_ACCOUNT); readonlySession = await loginViaApi(READONLY_ACCOUNT); noneSession = await loginViaApi(NONE_ACCOUNT); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } }); test.afterAll(async () => { if (!ENABLED) { await prisma.$disconnect(); return; } const ids = createdTaskIds.filter((id) => id !== ''); try { await prisma.outboxEvent.deleteMany({ where: { aggregateType: 'media_task', aggregateId: { in: ids } }, }); await prisma.mediaTask.deleteMany({ where: { id: { in: ids } } }); await prisma.mediaTask.deleteMany({ where: { createdBy: { in: [adminUserId, readonlyUserId, noneUserId].filter((id) => id !== '') }, }, }); await prisma.idempotencyKey.deleteMany({ where: { key: { startsWith: 'a6e2e-' } } }); await prisma.user.deleteMany({ where: { account: { in: [ADMIN_ACCOUNT, READONLY_ACCOUNT, NONE_ACCOUNT] } }, }); await prisma.role.deleteMany({ where: { code: { in: [READONLY_ROLE_CODE, NONE_ROLE_CODE] } } }); } finally { await apiContext?.dispose(); await prisma.$disconnect(); } }); // --------------------------------------------------------------------------- // 用例 // --------------------------------------------------------------------------- test('① 提交演练任务 → 列表 → 详情 SSE → 部分失败 → 仅重试失败项', async ({ page }) => { const session = requireAdminSession(); await injectSession(page, session); await page.goto('/material/tasks'); await expect(page.getByRole('heading', { name: '任务中心', level: 1 })).toBeVisible(); await expect(page.getByRole('button', { name: /提交演练任务/ }).first()).toBeVisible(); await page .getByRole('button', { name: /提交演练任务/ }) .first() .click(); const submitModal = page.locator('.semi-modal').filter({ hasText: '提交演练任务' }); await expect(submitModal).toBeVisible(); // 客户:选第一个可选项;条数 8;混合模式 await submitModal.locator('.semi-select').first().click(); await page.locator('.semi-select-option').first().click(); await submitModal.locator('.semi-input-number input').fill('8'); await submitModal.getByRole('button', { name: /混合成功/ }).click(); const submitResponsePromise = page.waitForResponse( (response) => response.request().method() === 'POST' && new URL(response.url()).pathname.endsWith('/media-tasks'), ); await submitModal.getByRole('button', { name: '提交演练任务' }).click(); const submitResponse = await submitResponsePromise; expect(submitResponse.status()).toBe(202); const submitBody = requireRecord((await submitResponse.json()) as unknown, '提交响应'); const taskId = String(submitBody.taskId); if (taskId === '' || taskId === 'undefined') { throw new Error(`提交响应缺少 taskId:${JSON.stringify(submitBody).slice(0, 300)}`); } createdTaskIds.push(taskId); await waitToast(page, /已提交/); const taskNo = `T-${taskId.slice(-6).toUpperCase()}`; await expect( page.locator('.semi-table-tbody tr').filter({ hasText: taskNo }).first(), ).toBeVisible({ timeout: 15_000 }); // 顶栏角标出现进行中任务,面板可打开 await page.locator('.lx-task-badge-trigger button').click(); await expect(page.getByText('进行中的任务').first()).toBeVisible({ timeout: 10_000 }); await expect(page.locator('.lx-task-badge-row').filter({ hasText: /演练/ }).first()).toBeVisible({ timeout: 10_000, }); await page.keyboard.press('Escape'); await page.goto(`/material/tasks/${taskId}`); await expect(page.getByRole('heading', { level: 1 })).toContainText('项'); await expect .poll(async () => (await fetchTaskDetail(session.accessToken, taskId)).status, { timeout: 40_000, message: '等待演练任务收敛为部分失败', }) .toBe('partial_failed'); await expect(page.getByText('部分失败').first()).toBeVisible({ timeout: 15_000 }); await expect(page.getByText('演练模拟失败').first()).toBeVisible({ timeout: 15_000 }); const retryButton = page.getByRole('button', { name: /重试失败项/ }).first(); await expect(retryButton).toBeVisible(); await retryButton.click(); const confirmModal = page.locator('.semi-modal').filter({ hasText: '重试失败项' }); await expect(confirmModal).toBeVisible(); await confirmModal.getByRole('button', { name: /重试 \d+ 项/ }).click(); await waitToast(page, /已提交重试/); await expect .poll(async () => (await fetchTaskDetail(session.accessToken, taskId)).status, { timeout: 40_000, message: '等待重试后的任务再次收敛', }) .toBe('partial_failed'); }); test('② 提交后立即取消 → 已取消且未开始项跳过', async ({ page }) => { const session = requireAdminSession(); const { status, taskId } = await createTaskViaApi( session.accessToken, { itemCount: 20, mode: 'success' }, `a6e2e-cancel-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`, ); expect(status).toBe(202); if (taskId === null) throw new Error('取消用例未拿到 taskId'); createdTaskIds.push(taskId); await injectSession(page, session); await page.goto(`/material/tasks/${taskId}`); await expect(page.getByRole('button', { name: '取消任务' }).first()).toBeVisible({ timeout: 15_000, }); await page.getByRole('button', { name: '取消任务' }).first().click(); const confirmModal = page.locator('.semi-modal').filter({ hasText: '取消后不可恢复' }); await expect(confirmModal).toBeVisible(); await confirmModal.getByRole('button', { name: '取消任务' }).click(); await waitToast(page, /任务已取消/); await expect .poll(async () => (await fetchTaskDetail(session.accessToken, taskId)).status, { timeout: 30_000, message: '等待任务取消', }) .toBe('canceled'); await expect(page.getByText('已取消').first()).toBeVisible({ timeout: 15_000 }); const detail = await fetchTaskDetail(session.accessToken, taskId); expect(detail.itemSkipped).toBeGreaterThan(0); }); test('③ 同一 Idempotency-Key 重复提交返回同一任务,不同 body 冲突', async () => { const session = requireAdminSession(); const key = `a6e2e-idem-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`; const first = await createTaskViaApi(session.accessToken, { itemCount: 3, mode: 'success' }, key); const second = await createTaskViaApi( session.accessToken, { itemCount: 3, mode: 'success' }, key, ); expect(first.status).toBe(202); expect(second.status).toBe(202); expect(second.taskId).toBe(first.taskId); if (first.taskId !== null) createdTaskIds.push(first.taskId); const fetchConflict = await fetch(`${API_BASE}/media-tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}`, 'Idempotency-Key': key, }, body: JSON.stringify({ type: 'demo', customerId, itemCount: 4, mode: 'success' }), }); expect(fetchConflict.status).toBe(409); const conflictBody = requireRecord((await fetchConflict.json()) as unknown, '冲突响应'); expect(conflictBody.code).toBe(40902); }); test('④ SSE Last-Event-ID 断点续推不重发历史', async () => { const session = requireAdminSession(); const { status, taskId } = await createTaskViaApi( session.accessToken, { itemCount: 20, mode: 'success' }, `a6e2e-sse-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`, ); expect(status).toBe(202); if (taskId === null) throw new Error('SSE 用例未拿到 taskId'); createdTaskIds.push(taskId); const firstId = await readSseFirstEventId(taskId, session.accessToken); const secondId = await readSseFirstEventId(taskId, session.accessToken, firstId); expect(Number(secondId)).toBeGreaterThan(Number(firstId)); }); test('⑤ 只读用户不渲染提交 / 取消 / 重试', async ({ page }) => { const admin = requireAdminSession(); const readonly = requireReadonlySession(); const { status, taskId } = await createTaskViaApi( admin.accessToken, { itemCount: 4, mode: 'mixed' }, `a6e2e-ro-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`, ); expect(status).toBe(202); if (taskId === null) throw new Error('只读用例未拿到 taskId'); createdTaskIds.push(taskId); // 等任务执行结束,保证详情页展示的是可取消 / 可重试的状态而不是排队中 await expect .poll(async () => (await fetchTaskDetail(admin.accessToken, taskId)).status, { timeout: 40_000, }) .not.toBe('queued'); await injectSession(page, readonly); await page.goto('/material/tasks'); await expect(page.getByRole('heading', { name: '任务中心', level: 1 })).toBeVisible(); await expect(page.getByRole('button', { name: /提交演练任务/ })).toHaveCount(0); await expect(page.getByText(/只读权限/).first()).toBeVisible({ timeout: 10_000 }); await page.goto(`/material/tasks/${taskId}`); await expect(page.getByRole('heading', { level: 1 })).toContainText('项'); await expect(page.getByRole('button', { name: '取消任务' })).toHaveCount(0); await expect(page.getByRole('button', { name: /重试失败项/ })).toHaveCount(0); }); test('⑥ 无 material:read 用户访问任务中心 → 403', async ({ page }) => { const none = requireNoneSession(); await injectSession(page, none); await page.goto('/material/tasks'); await expect(page).toHaveURL(/\/403$/, { timeout: 15_000 }); });