背景:A6-QA-01 要求验证幂等 / 取消 / 重试 / 断线续推 / 权限三态;同时本地无 ~/Library/Caches 写权限,需要可复现的浏览器安装方式。 改动: - 新增 e2e/a6-tasks.spec.ts(A6_E2E=1 门控):6 条用例覆盖提交 → 列表 → 详情 SSE → 部分失败 → 仅重试失败项、立即取消、同 Idempotency-Key 复用与 40902、Last-Event-ID 断点续推、只读不渲染写操作、无 material:read → 403;beforeAll 用 Prisma 建临时超管 / 只读 / 无权限账号与角色,afterAll 清理任务、幂等键、outbox 与账号角色(审计 append-only 保留)。 - playwright.config.ts 支持 PLAYWRIGHT_CHANNEL:未设置时本地用系统 Chrome,显式空串(CI)用 Playwright 自带 Chromium。 - eslint.config.mjs 忽略本地 .dev(Playwright 浏览器与临时脚本)。 - e2e/README.md 补充 A6 用例与运行方式。 影响面:默认(无 A6_E2E=1)仍 skip,CI pnpm test:e2e 行为不变;仅本地 e2e 配置增加环境变量支持。 回滚:撤销提交,删除 spec 与 channel 逻辑即可。 验证:A6_E2E=1 pnpm exec playwright test e2e/a6-tasks.spec.ts 6/6(约 39s,连跑多次稳定);pnpm test:e2e 默认套件不受影响。
563 lines
21 KiB
TypeScript
563 lines
21 KiB
TypeScript
/// <reference lib="dom" />
|
||
/**
|
||
* 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<string>(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<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||
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<void> {
|
||
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<QaSession> {
|
||
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<void> {
|
||
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<string> {
|
||
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<TaskDetail> {
|
||
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<string> {
|
||
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<void> {
|
||
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 });
|
||
});
|