195339d0f8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m49s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m17s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m13s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m47s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m57s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m34s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m13s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- core-generation.spec.ts: 素材选择从 checkbox 改为点击卡片+验证✓标记 - test_asset.spec.ts: 注册接口增加 429 限流重试逻辑
306 lines
9.5 KiB
TypeScript
Executable File
306 lines
9.5 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 },
|
||
})
|
||
}
|
||
|
||
async function registerWithRetry(
|
||
request: APIRequestContext,
|
||
email: string,
|
||
username: string,
|
||
password: string,
|
||
displayName: string,
|
||
maxRetries = 2,
|
||
) {
|
||
for (let i = 0; i <= maxRetries; i++) {
|
||
const response = await request.post(`${apiBase}/auth/register`, {
|
||
data: { email, password, username, display_name: displayName },
|
||
})
|
||
if (response.status() !== 429) return response
|
||
console.log(`[register] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||
await new Promise((r) => setTimeout(r, 65000))
|
||
}
|
||
return request.post(`${apiBase}/auth/register`, {
|
||
data: { email, password, username, display_name: displayName },
|
||
})
|
||
}
|
||
|
||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||
const email = uniqueEmail(label)
|
||
const username = uniqueUsername(label)
|
||
|
||
const reg = await registerWithRetry(request, email, username, PASSWORD, `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,
|
||
}
|
||
}
|
||
|
||
/** 创建一个项目并返回 project id */
|
||
async function createProject(
|
||
request: APIRequestContext,
|
||
headers: Record<string, string>,
|
||
suffix: string,
|
||
): Promise<string> {
|
||
const resp = await request.post(`${apiBase}/projects`, {
|
||
headers,
|
||
data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" },
|
||
})
|
||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy()
|
||
const data = await resp.json()
|
||
return data.id
|
||
}
|
||
|
||
test.describe("素材库流程", () => {
|
||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||
test.describe.configure({ timeout: 180_000 })
|
||
|
||
test("创建素材库", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "lib-create")
|
||
const projectId = await createProject(request, headers, Date.now().toString())
|
||
|
||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
data: {
|
||
project_id: projectId,
|
||
name: `视频素材库 ${Date.now()}`,
|
||
kind: "video",
|
||
},
|
||
})
|
||
|
||
expect(
|
||
response.ok(),
|
||
`创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||
).toBeTruthy()
|
||
|
||
const data = await response.json()
|
||
expect(data.id, "应返回素材库 ID").toBeTruthy()
|
||
expect(data.name).toContain("视频素材库")
|
||
expect(data.kind).toBe("video")
|
||
expect(data.project_id).toBe(projectId)
|
||
})
|
||
|
||
test("创建素材库 - 无效 kind 反向", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "lib-badkind")
|
||
const projectId = await createProject(request, headers, Date.now().toString())
|
||
|
||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
data: {
|
||
project_id: projectId,
|
||
name: "Bad Kind Library",
|
||
kind: "invalid_kind",
|
||
},
|
||
})
|
||
|
||
// kind 有 pattern 校验 ^(video|voice|image)$,应返回 422
|
||
expect([400, 422]).toContain(response.status())
|
||
})
|
||
|
||
test("创建素材库 - 不存在的项目反向", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "lib-nopj")
|
||
|
||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
data: {
|
||
project_id: "nonexistent-project-999",
|
||
name: "Orphan Library",
|
||
kind: "video",
|
||
},
|
||
})
|
||
|
||
expect(response.status(), "不存在的项目应返回 404").toBe(404)
|
||
})
|
||
|
||
test("列出素材库", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "lib-list")
|
||
const projectId = await createProject(request, headers, Date.now().toString())
|
||
|
||
// 创建 2 个不同类型的素材库
|
||
await request.post(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
data: {
|
||
project_id: projectId,
|
||
name: `Video Lib ${Date.now()}`,
|
||
kind: "video",
|
||
},
|
||
})
|
||
await request.post(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
data: {
|
||
project_id: projectId,
|
||
name: `Image Lib ${Date.now()}`,
|
||
kind: "image",
|
||
},
|
||
})
|
||
|
||
// 列出(按 project_id 过滤)
|
||
const response = await request.get(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
params: { project_id: projectId },
|
||
})
|
||
|
||
expect(
|
||
response.ok(),
|
||
`列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||
).toBeTruthy()
|
||
|
||
const data = await response.json()
|
||
const items = data.items || []
|
||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2)
|
||
|
||
const kinds = items.map((i: { kind: string }) => i.kind)
|
||
expect(kinds).toContain("video")
|
||
expect(kinds).toContain("image")
|
||
})
|
||
|
||
test("创建素材记录 — POST /assets 已废弃返回 410", async ({ request }) => {
|
||
const { headers, userId } = await createAuthedUser(request, "asset-create")
|
||
const projectId = await createProject(request, headers, Date.now().toString())
|
||
|
||
// 创建素材库
|
||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
data: {
|
||
project_id: projectId,
|
||
name: `Asset Lib ${Date.now()}`,
|
||
kind: "video",
|
||
},
|
||
})
|
||
expect(lib.ok()).toBeTruthy()
|
||
const libData = await lib.json()
|
||
|
||
// POST /assets 已废弃,应返回 410 Gone
|
||
const response = await request.post(`${apiBase}/assets`, {
|
||
headers,
|
||
data: {
|
||
project_id: projectId,
|
||
library_id: libData.id,
|
||
name: `test_video_${Date.now()}.mp4`,
|
||
storage_key: `uploads/e2e/test_${Date.now()}.mp4`,
|
||
mime_type: "video/mp4",
|
||
metadata: { duration: 15.5, resolution: "1080p" },
|
||
file_size: 1024000,
|
||
status: "ready",
|
||
uploaded_by_user_id: userId,
|
||
},
|
||
})
|
||
|
||
expect(response.status()).toBe(410)
|
||
const data = await response.json()
|
||
expect(data.error?.code).toBe("HTTP_410")
|
||
})
|
||
|
||
test("列出素材", async ({ request }) => {
|
||
const { headers, userId } = await createAuthedUser(request, "asset-list")
|
||
const projectId = await createProject(request, headers, Date.now().toString())
|
||
|
||
// 创建素材库
|
||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||
headers,
|
||
data: {
|
||
project_id: projectId,
|
||
name: `List Lib ${Date.now()}`,
|
||
kind: "image",
|
||
},
|
||
})
|
||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
|
||
const libData = await lib.json()
|
||
|
||
// 通过 multipart upload 上传 2 个小图片作为测试素材
|
||
// 创建一个 1x1 的 PNG buffer
|
||
const tinyPng = Buffer.from(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||
"base64",
|
||
)
|
||
|
||
await request.post(`${apiBase}/upload`, {
|
||
headers,
|
||
multipart: {
|
||
project_id: projectId,
|
||
library_id: libData.id,
|
||
file: { name: "clip_a.png", mimeType: "image/png", buffer: tinyPng },
|
||
},
|
||
})
|
||
await request.post(`${apiBase}/upload`, {
|
||
headers,
|
||
multipart: {
|
||
project_id: projectId,
|
||
library_id: libData.id,
|
||
file: { name: "clip_b.png", mimeType: "image/png", buffer: tinyPng },
|
||
},
|
||
})
|
||
|
||
// 列出素材(可能需要等待 ingest job 完成)
|
||
let items: any[] = []
|
||
for (let i = 0; i < 10; i++) {
|
||
const response = await request.get(`${apiBase}/assets`, {
|
||
headers,
|
||
params: { library_id: libData.id },
|
||
})
|
||
expect(response.ok(), `列出素材应返回 2xx`).toBeTruthy()
|
||
const data = await response.json()
|
||
items = data.items || []
|
||
if (items.length >= 2) break
|
||
await new Promise((r) => setTimeout(r, 2000))
|
||
}
|
||
|
||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
|
||
})
|
||
|
||
test("未登录创建素材库 - 反向", async ({ request }) => {
|
||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||
data: {
|
||
project_id: "some-project",
|
||
name: "Unauthorized Library",
|
||
kind: "video",
|
||
},
|
||
})
|
||
|
||
expect([401, 403]).toContain(response.status())
|
||
})
|
||
})
|