Files
xiaoxia-saas/apps/web/e2e/test_project.spec.ts
T
xiaoxia 0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
ci: Prettier纳入两层防御体系 (#520)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:08:19 +08:00

220 lines
7.3 KiB
TypeScript
Executable File

/**
* 项目流程 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())
})
})