- web/: React + Vite + TypeScript 前端 - backend/: Django + DRF + SimpleJWT 后端 - prototype/: HTML 设计原型 - docs/: PRD 和设计评审文档 - test: 单元测试 + E2E 极限测试
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
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);
|
||
|
||
// 再请求 10s(20+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 环境)`);
|
||
});
|
||
});
|