/** * 去重流程 E2E 测试 * * 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、 * 删除记录、重试去重 */ import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "SmokePass123!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""; const routeBrowserApiToTestApi = async ( page: import("@playwright/test").Page, ) => { if (!apiOrigin) return; await page.route("**/api/v1/**", async (route) => { const sourceUrl = new URL(route.request().url()); const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, }); await route.fulfill({ response }); }); }; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; } function uniqueUsername(prefix: string): string { return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; } /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( request: APIRequestContext, email: string, password: string, maxRetries = 2, ) { for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, }); if (response.status() !== 429) return response; console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); await new Promise((r) => setTimeout(r, 65000)); } return request.post(`${apiBase}/auth/login`, { data: { email, password }, }); } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { const email = uniqueEmail(label); const username = uniqueUsername(label); const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, }); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); const regData = await reg.json(); const login = await loginWithRetry(request, email, PASSWORD); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); const loginData = await login.json(); return { headers: { Authorization: `Bearer ${loginData.access_token}` }, email, username, userId: regData.user_id, accessToken: loginData.access_token, }; } /** 在浏览器中设置登录态 */ async function setupAuthInBrowser( page: import("@playwright/test").Page, token: string, user: { id: string; email: string; username: string }, ) { await page.addInitScript( ({ token, user }) => { localStorage.setItem("access_token", token); localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), ); }, { token, user: { id: user.id, user_id: user.id, email: user.email, username: user.username, display_name: user.username, is_email_verified: true, email_verified: true, }, }, ); } test.describe("去重流程", () => { test.describe.configure({ timeout: 180_000 }); // ─── 上传页面加载 ────────────────────────────────── test("去重上传页面加载", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-load", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication"); // 页面容器 await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 页面标题 await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible(); // 描述 await expect( page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"), ).toBeVisible(); }); // ─── 上传区域展示 ────────────────────────────────── test("上传区域展示", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-upload-zone", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 拖拽上传区域 const uploadZone = page.locator(".dup-upload-zone"); await expect(uploadZone).toBeVisible(); // 上传图标和文字 await expect( uploadZone.getByText("点击或拖拽视频文件到此区域"), ).toBeVisible(); // 格式提示 await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible(); // 格式标签 await expect(page.locator(".dup-upload-formats")).toBeVisible(); // 选择文件按钮 const selectBtn = page.getByRole("button", { name: "选择文件" }); await expect(selectBtn).toBeVisible(); // 隐藏的文件 input const fileInput = page.locator('input[type="file"]'); await expect(fileInput).toHaveCount(1); }); // ─── 格式说明区 ──────────────────────────────────── test("格式说明和提示区域展示", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-info", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 右侧说明区 const infoCard = page.locator(".dup-info-card"); await expect(infoCard).toBeVisible(); // 查重说明 await expect(infoCard.getByText("查重说明")).toBeVisible(); // 支持格式 await expect(infoCard.getByText("支持格式")).toBeVisible(); // 温馨提示 await expect(infoCard.getByText("温馨提示")).toBeVisible(); // 格式标签 await expect(page.locator(".dup-format-tags")).toBeVisible(); }); // ─── 去重记录列表页面 ────────────────────────────── test("去重记录列表页面加载", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-list", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication/results"); // 页面容器 await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 页面标题 await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible(); // 筛选按钮 await expect(page.locator(".dup-filter")).toBeVisible(); // 上传查重按钮 await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible(); }); test("去重记录列表 - 空状态", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-list-empty", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 空状态(新用户没有记录) const emptyState = page.locator(".dup-results-empty"); await expect(emptyState).toBeVisible({ timeout: 10_000 }); await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible(); }); test("去重记录列表 - 风险等级筛选", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-filter", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 筛选按钮存在 const filterBtns = page.locator(".dup-filter-btn"); await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险 // 验证按钮文本 await expect(filterBtns.nth(0)).toHaveText("全部"); await expect(filterBtns.nth(1)).toHaveText("低风险"); await expect(filterBtns.nth(2)).toHaveText("中风险"); await expect(filterBtns.nth(3)).toHaveText("高风险"); // 默认选中"全部" await expect(filterBtns.nth(0)).toHaveClass(/active/); // 点击低风险 await filterBtns.nth(1).click(); await expect(filterBtns.nth(1)).toHaveClass(/active/); }); test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-nav", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 点击上传查重按钮 await page.getByRole("button", { name: "上传查重" }).click(); await expect(page).toHaveURL(/\/app\/duplication$/); await expect(page.locator(".dup-upload-zone")).toBeVisible(); }); // ─── 去重详情页 ──────────────────────────────────── test("去重详情页 - 通过 API 创建测试数据后访问", async ({ page, request, }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "dup-detail"); // 先上传一个文件进行查重,获取 record id const uploadResp = await request.post(`${apiBase}/duplication/upload`, { headers, multipart: { file: { name: "e2e_dup_test.mp4", mimeType: "video/mp4", buffer: Buffer.from("e2e duplication test data"), }, }, }); // 如果查重 API 不可用,跳过详情页测试 if (!uploadResp.ok()) { console.log( `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`, ); return; } const uploadData = await uploadResp.json(); const recordId = uploadData.id; expect(recordId, "应返回查重记录 ID").toBeTruthy(); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); // 访问详情页 await page.goto(`/app/duplication/${recordId}`); // 页面应正常渲染 await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 验证无错误 await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, { timeout: 5_000, }); }); // ─── 删除记录 ────────────────────────────────────── test("去重记录删除 - API 验证", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers } = await createAuthedUser(request, "dup-delete"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { headers, multipart: { file: { name: "e2e_dup_delete.mp4", mimeType: "video/mp4", buffer: Buffer.from("e2e duplication delete test"), }, }, }); if (!uploadResp.ok()) { console.log( `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`, ); return; } const uploadData = await uploadResp.json(); const recordId = uploadData.id; // 验证记录存在 const listResp = await request.get(`${apiBase}/duplication/records`, { headers, }); if (listResp.ok()) { const records = await listResp.json(); const recordExists = Array.isArray(records) ? records.some((r: { id: string }) => r.id === recordId) : (records.items || []).some((r: { id: string }) => r.id === recordId); expect(recordExists, "记录应存在于列表中").toBeTruthy(); } // 删除记录 const deleteResp = await request.delete( `${apiBase}/duplication/records/${recordId}`, { headers }, ); expect( deleteResp.ok(), `删除查重记录应成功: ${deleteResp.status()}`, ).toBeTruthy(); // 验证记录已删除 const listAfterResp = await request.get(`${apiBase}/duplication/records`, { headers, }); if (listAfterResp.ok()) { const recordsAfter = await listAfterResp.json(); const recordStillExists = Array.isArray(recordsAfter) ? recordsAfter.some((r: { id: string }) => r.id === recordId) : (recordsAfter.items || []).some( (r: { id: string }) => r.id === recordId, ); expect(recordStillExists, "记录应已被删除").toBeFalsy(); } }); test("去重记录删除 - UI 验证", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "dup-delete-ui"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { headers, multipart: { file: { name: "e2e_dup_ui_delete.mp4", mimeType: "video/mp4", buffer: Buffer.from("e2e duplication ui delete test"), }, }, }); if (!uploadResp.ok()) { console.log( `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`, ); return; } await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 记录卡片应存在 const resultCard = page.locator(".dup-result-card").first(); const cardVisible = await resultCard .isVisible({ timeout: 10_000 }) .catch(() => false); if (cardVisible) { // 删除按钮存在 const deleteBtn = resultCard.getByRole("button").filter({ hasText: "🗑️", }); await expect(deleteBtn).toBeVisible(); // 删除按钮点击 - 会触发 confirm 对话框 // 这里我们通过监听 confirm 来确认删除 page.once("dialog", async (dialog) => { expect(dialog.message()).toContain("确定删除"); await dialog.accept(); }); // 监听删除请求 const deletePromise = page .waitForResponse( (resp) => resp.url().includes("/duplication/records/") && resp.request().method() === "DELETE", { timeout: 10_000 }, ) .catch(() => null); await deleteBtn.click(); const deleteResp = await deletePromise; if (deleteResp) { expect(deleteResp.ok(), "删除请求应成功").toBeTruthy(); } } }); // ─── 重试去重 ────────────────────────────────────── test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "dup-retry"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { headers, multipart: { file: { name: "e2e_dup_retry.mp4", mimeType: "video/mp4", buffer: Buffer.from("e2e duplication retry test"), }, }, }); if (!uploadResp.ok()) { console.log( `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`, ); return; } await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 记录列表中至少有一条记录 const resultCard = page.locator(".dup-result-card").first(); const cardVisible = await resultCard .isVisible({ timeout: 10_000 }) .catch(() => false); if (cardVisible) { // 验证记录卡片基本结构 await expect(resultCard.locator(".dup-result-card-body")).toBeVisible(); await expect(resultCard.locator(".dup-result-card-score")).toBeVisible(); // 检查是否有重试按钮(失败状态才显示) // 新上传的记录可能是处理中或完成状态,不一定显示重试按钮 // 这里只验证 API 重试接口可用 const uploadData = await uploadResp.json(); const recordId = uploadData.id; const retryResp = await request.post( `${apiBase}/duplication/records/${recordId}/retry`, { headers }, ); // 重试接口应返回 2xx 或明确的状态码 expect(retryResp.status()).toBeLessThan(500); } }); // ─── 未登录访问 ──────────────────────────────────── test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => { await page.goto("/app/duplication"); await expect(page).toHaveURL(/\/login/); }); test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => { await page.goto("/app/duplication/results"); await expect(page).toHaveURL(/\/login/); }); });