video-shuoshan/web/test/e2e/phase3-extreme.spec.ts
zyc ffe92f7b15 Initial commit: 即梦视频生成平台
- web/: React + Vite + TypeScript 前端
- backend/: Django + DRF + SimpleJWT 后端
- prototype/: HTML 设计原型
- docs/: PRD 和设计评审文档
- test: 单元测试 + E2E 极限测试
2026-03-13 09:59:33 +08:00

378 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect } from '@playwright/test';
let counter = 500;
function shortUid() {
counter++;
return `${counter}${Math.random().toString(36).slice(2, 7)}`;
}
const TEST_PASS = 'testpass123';
const BASE = 'http://localhost:8000';
/** Register user via API, return { username, tokens, user_id } */
async function registerUser(prefix = 'ex') {
const uid = shortUid();
const username = `${prefix}${uid}`;
const email = `${prefix}${uid}@t.co`;
const resp = await fetch(`${BASE}/api/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password: TEST_PASS }),
});
expect(resp.ok).toBeTruthy();
const body = await resp.json();
return { username, email, tokens: body.tokens, user_id: body.user.id };
}
/** Login as pre-existing admin (username: admin, password: admin123) */
async function loginAdmin() {
const resp = await fetch(`${BASE}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'admin123' }),
});
expect(resp.ok).toBeTruthy();
const body = await resp.json();
return body.tokens.access as string;
}
/** Generate video (POST /api/v1/video/generate) */
async function generate(accessToken: string, duration = 10) {
const form = new FormData();
form.append('prompt', 'stress test');
form.append('mode', 'universal');
form.append('model', 'seedance_2.0');
form.append('aspect_ratio', '16:9');
form.append('duration', String(duration));
return fetch(`${BASE}/api/v1/video/generate`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` },
body: form,
});
}
/** Set user quota via admin API */
async function setQuota(adminToken: string, userId: number, daily: number, monthly: number) {
return fetch(`${BASE}/api/v1/admin/users/${userId}/quota`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ daily_seconds_limit: daily, monthly_seconds_limit: monthly }),
});
}
/** Set user active status via admin API */
async function setUserStatus(adminToken: string, userId: number, isActive: boolean) {
return fetch(`${BASE}/api/v1/admin/users/${userId}/status`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_active: isActive }),
});
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1. 配额耗尽极限测试
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('极限测试: 日配额耗尽拦截', () => {
test('连续生成直至日配额耗尽,超限请求应返回 429', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('dq');
// 管理员将用户日配额设为 30s方便快速耗尽
const quotaResp = await setQuota(adminToken, user.user_id, 30, 6000);
expect(quotaResp.ok).toBeTruthy();
// 第 1 次: 10s → 累计 10s, 剩余 20s → 应成功
const r1 = await generate(user.tokens.access, 10);
expect(r1.status).toBe(202);
const d1 = await r1.json();
expect(d1.seconds_consumed).toBe(10);
expect(d1.remaining_seconds_today).toBe(20);
// 第 2 次: 10s → 累计 20s, 剩余 10s → 应成功
const r2 = await generate(user.tokens.access, 10);
expect(r2.status).toBe(202);
const d2 = await r2.json();
expect(d2.remaining_seconds_today).toBe(10);
// 第 3 次: 10s → 累计 30s, 剩余 0s → 应成功(刚好用完)
const r3 = await generate(user.tokens.access, 10);
expect(r3.status).toBe(202);
const d3 = await r3.json();
expect(d3.remaining_seconds_today).toBe(0);
// 第 4 次: 再请求 10s → 超限 → 应返回 429
const r4 = await generate(user.tokens.access, 10);
expect(r4.status).toBe(429);
const d4 = await r4.json();
expect(d4.error).toBe('quota_exceeded');
expect(d4.message).toContain('今日');
expect(d4.daily_seconds_used).toBe(30);
// 第 5 次: 即使只请求 1s 也应被拦截
const r5 = await generate(user.tokens.access, 1);
expect(r5.status).toBe(429);
});
test('单次请求超出剩余额度应被拦截', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('ds');
// 日配额设为 15s
await setQuota(adminToken, user.user_id, 15, 6000);
// 先消耗 10s
const r1 = await generate(user.tokens.access, 10);
expect(r1.status).toBe(202);
// 再请求 10s剩余仅 5s→ 应被拦截
const r2 = await generate(user.tokens.access, 10);
expect(r2.status).toBe(429);
const d2 = await r2.json();
expect(d2.error).toBe('quota_exceeded');
// 但请求 5s 应该成功(刚好用完)
const r3 = await generate(user.tokens.access, 5);
expect(r3.status).toBe(202);
expect((await r3.json()).remaining_seconds_today).toBe(0);
});
});
test.describe('极限测试: 月配额耗尽拦截', () => {
test('月配额耗尽后应返回 429 并提示月度限制', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('mq');
// 日配额 100s月配额仅 25s月配额比日配额小优先触发月限制
await setQuota(adminToken, user.user_id, 100, 25);
// 消耗 10s
const r1 = await generate(user.tokens.access, 10);
expect(r1.status).toBe(202);
// 消耗 10s累计 20s
const r2 = await generate(user.tokens.access, 10);
expect(r2.status).toBe(202);
// 再请求 10s20+10=30 > 25 月配额)→ 月配额拦截
const r3 = await generate(user.tokens.access, 10);
expect(r3.status).toBe(429);
const d3 = await r3.json();
expect(d3.error).toBe('quota_exceeded');
expect(d3.message).toContain('本月');
expect(d3.monthly_seconds_used).toBe(20);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2. 账号禁用/启用功能测试
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('极限测试: 账号禁用后权限验证', () => {
test('禁用账号后,使用已有 token 调用生成接口应被拒绝', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('dis');
// 先确认用户可以正常生成
const r1 = await generate(user.tokens.access, 10);
expect(r1.status).toBe(202);
// 管理员禁用该用户
const statusResp = await setUserStatus(adminToken, user.user_id, false);
expect(statusResp.ok).toBeTruthy();
const statusBody = await statusResp.json();
expect(statusBody.is_active).toBe(false);
// 使用已有 token 尝试生成 → 应被拒绝401 或 403
const r2 = await generate(user.tokens.access, 10);
expect([401, 403]).toContain(r2.status);
// 使用已有 token 访问个人信息 → 也应被拒绝
const meResp = await fetch(`${BASE}/api/v1/auth/me`, {
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
});
expect([401, 403]).toContain(meResp.status);
});
test('禁用账号后,重新登录应失败', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('dl');
// 管理员禁用该用户
await setUserStatus(adminToken, user.user_id, false);
// 尝试登录 → 应失败
const loginResp = await fetch(`${BASE}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password: TEST_PASS }),
});
expect(loginResp.ok).toBeFalsy();
expect(loginResp.status).toBe(401);
});
test('重新启用账号后,用户可以登录并正常生成', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('re');
// 禁用
await setUserStatus(adminToken, user.user_id, false);
// 确认无法登录
const loginFail = await fetch(`${BASE}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password: TEST_PASS }),
});
expect(loginFail.status).toBe(401);
// 重新启用
const enableResp = await setUserStatus(adminToken, user.user_id, true);
expect(enableResp.ok).toBeTruthy();
expect((await enableResp.json()).is_active).toBe(true);
// 重新登录 → 应成功
const loginOk = await fetch(`${BASE}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password: TEST_PASS }),
});
expect(loginOk.ok).toBeTruthy();
const newTokens = (await loginOk.json()).tokens;
// 使用新 token 生成 → 应成功
const r = await generate(newTokens.access, 10);
expect(r.status).toBe(202);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3. 管理员动态调整额度测试
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('极限测试: 管理员调整额度后立即生效', () => {
test('调低额度后,已消耗量超新限制的请求应被拦截', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('ql');
// 默认日配额 600s先消耗 50s
for (let i = 0; i < 5; i++) {
const r = await generate(user.tokens.access, 10);
expect(r.status).toBe(202);
}
// 管理员将日配额调低为 40s低于已消耗的 50s
await setQuota(adminToken, user.user_id, 40, 6000);
// 再请求 → 应被拦截(已用 50s > 新限 40s
const r = await generate(user.tokens.access, 1);
expect(r.status).toBe(429);
const d = await r.json();
expect(d.error).toBe('quota_exceeded');
expect(d.daily_seconds_used).toBe(50);
});
test('调高额度后,之前被拦截的请求应可以通过', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('qh');
// 日配额设为 20s
await setQuota(adminToken, user.user_id, 20, 6000);
// 消耗 20s 用完
const r1 = await generate(user.tokens.access, 10);
expect(r1.status).toBe(202);
const r2 = await generate(user.tokens.access, 10);
expect(r2.status).toBe(202);
// 确认被拦截
const r3 = await generate(user.tokens.access, 10);
expect(r3.status).toBe(429);
// 管理员调高日配额到 100s
await setQuota(adminToken, user.user_id, 100, 6000);
// 现在应该可以继续生成(已用 20s新限 100s还剩 80s
const r4 = await generate(user.tokens.access, 10);
expect(r4.status).toBe(202);
const d4 = await r4.json();
expect(d4.remaining_seconds_today).toBe(70); // 100 - 20 - 10 = 70
});
test('调整月配额同样立即生效', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('qm');
// 日配额 200s月配额 30s
await setQuota(adminToken, user.user_id, 200, 30);
// 消耗 20s
const r1 = await generate(user.tokens.access, 10);
expect(r1.status).toBe(202);
const r2 = await generate(user.tokens.access, 10);
expect(r2.status).toBe(202);
// 再请求 15s → 20+15=35 > 30 月配额 → 拦截
const r3 = await generate(user.tokens.access, 15);
expect(r3.status).toBe(429);
// 调高月配额到 100s
await setQuota(adminToken, user.user_id, 200, 100);
// 现在可以继续
const r4 = await generate(user.tokens.access, 15);
expect(r4.status).toBe(202);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 4. 边界条件测试
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('极限测试: 边界条件', () => {
test('配额恰好为 0s 时任何请求都应被拦截', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('z0');
// 日配额设为 0
await setQuota(adminToken, user.user_id, 0, 6000);
const r = await generate(user.tokens.access, 1);
expect(r.status).toBe(429);
});
test('并发请求竞态条件检测SQLite 无行锁,记录实际行为)', async () => {
const adminToken = await loginAdmin();
const user = await registerUser('cc');
// 日配额 15s并发发 3 个 10s 请求
await setQuota(adminToken, user.user_id, 15, 6000);
const results = await Promise.all([
generate(user.tokens.access, 10),
generate(user.tokens.access, 10),
generate(user.tokens.access, 10),
]);
const statuses = results.map(r => r.status);
const successCount = statuses.filter(s => s === 202).length;
// 记录并发行为:
// - MySQL/PostgreSQL + select_for_update最多成功 1 个
// - SQLite 无行锁:可能全部成功(已知限制)
// 此测试验证并发请求被正确处理(无 500 错误)
expect(statuses.every(s => s === 202 || s === 429)).toBeTruthy();
// 如果使用 MySQL 生产环境,取消下面注释启用严格断言:
// expect(successCount).toBeLessThanOrEqual(1);
console.log(`并发测试结果: ${successCount} 个成功, ${3 - successCount} 个被拦截 (SQLite 环境)`);
});
});