- web/: React + Vite + TypeScript 前端 - backend/: Django + DRF + SimpleJWT 后端 - prototype/: HTML 设计原型 - docs/: PRD 和设计评审文档 - test: 单元测试 + E2E 极限测试
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
let counter = 100;
|
|
function shortUid() {
|
|
counter++;
|
|
return `${counter}${Math.random().toString(36).slice(2, 7)}`;
|
|
}
|
|
|
|
const TEST_PASS = 'testpass123';
|
|
|
|
/**
|
|
* Helper: register a user via API and get tokens
|
|
*/
|
|
async function registerUser(page: any, prefix = 'p3') {
|
|
const uid = shortUid();
|
|
const username = `${prefix}${uid}`;
|
|
const email = `${prefix}${uid}@t.co`;
|
|
const resp = await page.request.post('/api/v1/auth/register', {
|
|
data: { username, email, password: TEST_PASS },
|
|
});
|
|
expect(resp.ok()).toBeTruthy();
|
|
const body = await resp.json();
|
|
return { username, email, ...body };
|
|
}
|
|
|
|
/**
|
|
* Helper: register user + promote to admin via API + re-login
|
|
*/
|
|
async function setupAdminUser(page: any) {
|
|
const user = await registerUser(page, 'ad');
|
|
|
|
// Promote to admin via backend management command
|
|
// We use a custom endpoint trick: register -> promote -> re-login
|
|
// Since we can't run Django management commands from E2E, we promote via the
|
|
// existing admin user or use the tokens directly
|
|
|
|
// Actually, let's use the Django management API. We'll create a known admin.
|
|
// Instead, let's register and use a Python script
|
|
// For E2E, let's use the backend to promote:
|
|
const promoteResp = await page.request.fetch(`http://localhost:8000/api/v1/auth/me`, {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
|
|
// Since we can't promote via API, let's use a workaround:
|
|
// We'll create the admin via Django shell before tests
|
|
// For now, register + set tokens in localStorage
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* Helper: login via localStorage tokens
|
|
*/
|
|
async function loginWithTokens(page: any, tokens: { access: string; refresh: string }) {
|
|
await page.goto('/login');
|
|
await page.evaluate((t: { access: string; refresh: string }) => {
|
|
localStorage.setItem('access_token', t.access);
|
|
localStorage.setItem('refresh_token', t.refresh);
|
|
}, tokens);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Profile Page Tests
|
|
// ──────────────────────────────────────────────
|
|
|
|
test.describe('Phase 3: Profile Page (/profile)', () => {
|
|
test('should be accessible for authenticated users', async ({ page }) => {
|
|
const user = await registerUser(page, 'pf');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/profile');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Should show profile page elements
|
|
await expect(page.getByText('个人中心')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('should display consumption overview section', async ({ page }) => {
|
|
const user = await registerUser(page, 'po');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/profile');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await expect(page.getByText('消费概览')).toBeVisible({ timeout: 10000 });
|
|
// Should show daily and monthly quota info (今日额度 appears twice: gauge label + quota card)
|
|
await expect(page.getByText('今日额度').first()).toBeVisible();
|
|
await expect(page.getByText('本月额度')).toBeVisible();
|
|
});
|
|
|
|
test('should display consumption trend section with period toggle', async ({ page }) => {
|
|
const user = await registerUser(page, 'pt');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/profile');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await expect(page.getByText('消费趋势')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText('近7天')).toBeVisible();
|
|
await expect(page.getByText('近30天')).toBeVisible();
|
|
});
|
|
|
|
test('should display consumption records section', async ({ page }) => {
|
|
const user = await registerUser(page, 'pr');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/profile');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await expect(page.getByText('消费记录')).toBeVisible({ timeout: 10000 });
|
|
// New user has no records
|
|
await expect(page.getByText('暂无记录')).toBeVisible();
|
|
});
|
|
|
|
test('should have back-to-home navigation', async ({ page }) => {
|
|
const user = await registerUser(page, 'pb');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/profile');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const backBtn = page.getByText('返回首页');
|
|
await expect(backBtn).toBeVisible({ timeout: 10000 });
|
|
await backBtn.click();
|
|
|
|
// Should navigate to home
|
|
await page.locator('textarea').waitFor({ state: 'visible', timeout: 10000 });
|
|
});
|
|
|
|
test('should have logout button', async ({ page }) => {
|
|
const user = await registerUser(page, 'pl');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/profile');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await expect(page.getByText('退出').first()).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('should redirect unauthenticated users to /login', async ({ page }) => {
|
|
await page.goto('/profile');
|
|
await page.waitForURL('**/login', { timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────
|
|
// UserInfoBar Phase 3 Navigation
|
|
// ──────────────────────────────────────────────
|
|
|
|
test.describe('Phase 3: UserInfoBar Quota & Navigation', () => {
|
|
test('should display seconds-based quota in UserInfoBar', async ({ page }) => {
|
|
const user = await registerUser(page, 'ub');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Should show seconds format: "剩余: Xs/Xs(日)"
|
|
await expect(page.getByText(/剩余.*s.*s.*日/)).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('should have profile link in UserInfoBar', async ({ page }) => {
|
|
const user = await registerUser(page, 'up');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const profileBtn = page.getByText('个人中心');
|
|
await expect(profileBtn).toBeVisible({ timeout: 10000 });
|
|
await profileBtn.click();
|
|
|
|
await page.waitForURL('**/profile', { timeout: 10000 });
|
|
await expect(page.getByText('消费概览')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Admin Routes Access Control
|
|
// ──────────────────────────────────────────────
|
|
|
|
test.describe('Phase 3: Admin Routes Access Control', () => {
|
|
test('non-admin user should be redirected away from /admin/dashboard', async ({ page }) => {
|
|
const user = await registerUser(page, 'na');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/admin/dashboard');
|
|
// Should redirect to / (non-admin)
|
|
await page.locator('textarea').waitFor({ state: 'visible', timeout: 10000 });
|
|
});
|
|
|
|
test('non-admin user should be redirected away from /admin/users', async ({ page }) => {
|
|
const user = await registerUser(page, 'nu');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/admin/users');
|
|
await page.locator('textarea').waitFor({ state: 'visible', timeout: 10000 });
|
|
});
|
|
|
|
test('non-admin user should be redirected away from /admin/records', async ({ page }) => {
|
|
const user = await registerUser(page, 'nr');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/admin/records');
|
|
await page.locator('textarea').waitFor({ state: 'visible', timeout: 10000 });
|
|
});
|
|
|
|
test('non-admin user should be redirected away from /admin/settings', async ({ page }) => {
|
|
const user = await registerUser(page, 'ns');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/admin/settings');
|
|
await page.locator('textarea').waitFor({ state: 'visible', timeout: 10000 });
|
|
});
|
|
|
|
test('/admin should redirect to /admin/dashboard', async ({ page }) => {
|
|
const user = await registerUser(page, 'ar');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
// This will redirect non-admin to / but we verify the redirect chain includes /admin/dashboard
|
|
await page.goto('/admin');
|
|
// Non-admin gets redirected to /
|
|
await page.locator('textarea').waitFor({ state: 'visible', timeout: 10000 });
|
|
});
|
|
|
|
test('unauthenticated access to /admin should redirect to /login', async ({ page }) => {
|
|
await page.goto('/admin/dashboard');
|
|
await page.waitForURL('**/login', { timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Backend API Integration Tests (via page.request)
|
|
// ──────────────────────────────────────────────
|
|
|
|
test.describe('Phase 3: Backend API Integration', () => {
|
|
test('GET /api/v1/auth/me should return seconds-based quota', async ({ page }) => {
|
|
const user = await registerUser(page, 'qm');
|
|
|
|
const resp = await page.request.get('/api/v1/auth/me', {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
expect(resp.ok()).toBeTruthy();
|
|
|
|
const data = await resp.json();
|
|
expect(data.quota).toBeDefined();
|
|
expect(data.quota.daily_seconds_limit).toBe(600);
|
|
expect(data.quota.monthly_seconds_limit).toBe(6000);
|
|
expect(data.quota.daily_seconds_used).toBe(0);
|
|
expect(data.quota.monthly_seconds_used).toBe(0);
|
|
});
|
|
|
|
test('GET /api/v1/profile/overview should return consumption data', async ({ page }) => {
|
|
const user = await registerUser(page, 'ov');
|
|
|
|
const resp = await page.request.get('/api/v1/profile/overview?period=7d', {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
expect(resp.ok()).toBeTruthy();
|
|
|
|
const data = await resp.json();
|
|
expect(data.daily_seconds_limit).toBe(600);
|
|
expect(data.monthly_seconds_limit).toBe(6000);
|
|
expect(data.daily_trend).toHaveLength(7);
|
|
expect(data.total_seconds_used).toBe(0);
|
|
});
|
|
|
|
test('GET /api/v1/profile/overview should support 30d period', async ({ page }) => {
|
|
const user = await registerUser(page, 'o3');
|
|
|
|
const resp = await page.request.get('/api/v1/profile/overview?period=30d', {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
expect(resp.ok()).toBeTruthy();
|
|
|
|
const data = await resp.json();
|
|
expect(data.daily_trend).toHaveLength(30);
|
|
});
|
|
|
|
test('GET /api/v1/profile/records should return paginated records', async ({ page }) => {
|
|
const user = await registerUser(page, 'rc');
|
|
|
|
const resp = await page.request.get('/api/v1/profile/records?page=1&page_size=10', {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
expect(resp.ok()).toBeTruthy();
|
|
|
|
const data = await resp.json();
|
|
expect(data.total).toBe(0);
|
|
expect(data.page).toBe(1);
|
|
expect(data.page_size).toBe(10);
|
|
expect(data.results).toEqual([]);
|
|
});
|
|
|
|
test('POST /api/v1/video/generate should consume seconds', async ({ page }) => {
|
|
const user = await registerUser(page, 'vg');
|
|
|
|
// Use multipart without explicit Content-Type (Playwright sets it automatically with boundary)
|
|
const resp = await page.request.post('/api/v1/video/generate', {
|
|
headers: {
|
|
'Authorization': `Bearer ${user.tokens.access}`,
|
|
},
|
|
multipart: {
|
|
prompt: 'test video generation',
|
|
mode: 'universal',
|
|
model: 'seedance_2.0',
|
|
aspect_ratio: '16:9',
|
|
duration: '10',
|
|
},
|
|
});
|
|
|
|
const data = await resp.json();
|
|
expect(resp.status()).toBe(202);
|
|
expect(data.seconds_consumed).toBe(10);
|
|
expect(data.remaining_seconds_today).toBe(590); // 600 - 10
|
|
expect(data.task_id).toBeDefined();
|
|
});
|
|
|
|
test('admin endpoints should return 403 for non-admin users', async ({ page }) => {
|
|
const user = await registerUser(page, 'fa');
|
|
|
|
const statsResp = await page.request.get('/api/v1/admin/stats', {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
expect(statsResp.status()).toBe(403);
|
|
|
|
const usersResp = await page.request.get('/api/v1/admin/users', {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
expect(usersResp.status()).toBe(403);
|
|
|
|
const settingsResp = await page.request.get('/api/v1/admin/settings', {
|
|
headers: { 'Authorization': `Bearer ${user.tokens.access}` },
|
|
});
|
|
expect(settingsResp.status()).toBe(403);
|
|
});
|
|
|
|
test('unauthenticated requests should return 401', async ({ page }) => {
|
|
const meResp = await page.request.get('/api/v1/auth/me');
|
|
expect(meResp.status()).toBe(401);
|
|
|
|
const profileResp = await page.request.get('/api/v1/profile/overview');
|
|
expect(profileResp.status()).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Trend Period Toggle (E2E)
|
|
// ──────────────────────────────────────────────
|
|
|
|
test.describe('Phase 3: Profile Trend Toggle', () => {
|
|
test('clicking 30d tab should update trend period', async ({ page }) => {
|
|
const user = await registerUser(page, 'tt');
|
|
await loginWithTokens(page, user.tokens);
|
|
|
|
await page.goto('/profile');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await expect(page.getByText('消费趋势')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click 30d tab
|
|
const tab30d = page.getByText('近30天');
|
|
await expect(tab30d).toBeVisible();
|
|
await tab30d.click();
|
|
|
|
// Should still be on profile page with updated trend
|
|
await expect(page.getByText('消费趋势')).toBeVisible();
|
|
});
|
|
});
|