/** * 项目流程 E2E 测试 * * 覆盖:创建项目、列出项目、获取项目详情 * 每个测试独立,先注册登录获取 auth token。 */ import { expect, test, type APIRequestContext } from "@playwright/test" const PASSWORD = "Test123456!" const apiBase = process.env.E2E_API_BASE || "/api/v1" 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 } */ 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, } } test.describe("项目流程", () => { // 登录限流 10次/60s,测试可能触发限流等待,给足够超时 test.describe.configure({ timeout: 180_000 }) test("创建项目", async ({ request }) => { const { headers } = await createAuthedUser(request, "proj-create") const projectName = `E2E 测试项目 ${Date.now()}` const response = await request.post(`${apiBase}/projects`, { headers, data: { name: projectName, description: "Playwright E2E 回归测试创建", }, }) expect( response.ok(), `创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, ).toBeTruthy() const data = await response.json() expect(data.id, "应返回项目 ID").toBeTruthy() expect(data.name).toBe(projectName) expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy() }) test("创建项目名称为空 - 反向", async ({ request }) => { const { headers } = await createAuthedUser(request, "proj-empty") const response = await request.post(`${apiBase}/projects`, { headers, data: { name: "", description: "Should fail" }, }) // name 有 min_length=1 约束,应返回 422 expect([400, 422]).toContain(response.status()) }) test("列出项目", async ({ request }) => { const { headers } = await createAuthedUser(request, "proj-list") // 先创建 2 个项目 await request.post(`${apiBase}/projects`, { headers, data: { name: `List Proj A ${Date.now()}` }, }) await request.post(`${apiBase}/projects`, { headers, data: { name: `List Proj B ${Date.now()}` }, }) // 列出 const response = await request.get(`${apiBase}/projects`, { headers }) expect( response.ok(), `列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, ).toBeTruthy() const data = await response.json() const items = data.items || data.projects || data || [] expect(Array.isArray(items)).toBeTruthy() expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2) }) test("获取项目详情", async ({ request }) => { const { headers } = await createAuthedUser(request, "proj-detail") // 先创建 const created = await request.post(`${apiBase}/projects`, { headers, data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" }, }) expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy() const { id: projectId } = await created.json() // 获取详情 const response = await request.get(`${apiBase}/projects/${projectId}`, { headers, }) expect( response.ok(), `获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`, ).toBeTruthy() const data = await response.json() expect(data.id).toBe(projectId) expect(data.name).toBeTruthy() expect(data.owner_user_id).toBeTruthy() }) test("获取不存在的项目 - 反向", async ({ request }) => { const { headers } = await createAuthedUser(request, "proj-404") const response = await request.get(`${apiBase}/projects/nonexistent-project-id-999`, { headers, }) expect(response.status(), "不存在的项目应返回 404").toBe(404) }) test("未登录列出项目 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/projects`) expect([401, 403]).toContain(response.status()) }) test("未授权访问他人项目 - 反向", async ({ request }) => { // 用户 A 创建项目 const { headers: headersA } = await createAuthedUser(request, "proj-owner") const created = await request.post(`${apiBase}/projects`, { headers: headersA, data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" }, }) expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy() const { id: projectId } = await created.json() // 用户 B 尝试访问用户 A 的项目 const { headers: headersB } = await createAuthedUser(request, "proj-intruder") const response = await request.get(`${apiBase}/projects/${projectId}`, { headers: headersB, }) // 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性 expect([403, 404]).toContain(response.status()) }) test("未授权删除他人项目 - 反向", async ({ request }) => { // 用户 A 创建项目 const { headers: headersA } = await createAuthedUser(request, "proj-del-owner") const created = await request.post(`${apiBase}/projects`, { headers: headersA, data: { name: `Delete Test Proj ${Date.now()}` }, }) expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy() const { id: projectId } = await created.json() // 用户 B 尝试删除用户 A 的项目 const { headers: headersB } = await createAuthedUser(request, "proj-del-attempt") const response = await request.delete(`${apiBase}/projects/${projectId}`, { headers: headersB, }) expect([403, 404]).toContain(response.status()) }) test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => { const { headers } = await createAuthedUser(request, "proj-badid") const response = await request.get(`${apiBase}/projects/`, { headers }) // 空 ID 或无效格式应返回 404 或 422 expect([400, 404, 422]).toContain(response.status()) }) })