import { describe, it, expect, vi, beforeEach } from 'vitest'; import { readFileSync } from 'fs'; import { resolve } from 'path'; const ROOT = resolve(__dirname, '../..'); /** * Phase 3 Feature Tests * Tests for: Seconds-based quota, Admin multi-page layout, Profile page, ECharts integration */ // ────────────────────────────────────────────── // 1. Seconds-based Quota System Verification // ────────────────────────────────────────────── describe('Phase 3: Seconds-Based Quota System', () => { it('User model should have daily_seconds_limit and monthly_seconds_limit fields', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/accounts/models.py'), 'utf-8'); expect(src).toContain('daily_seconds_limit'); expect(src).toContain('monthly_seconds_limit'); expect(src).not.toContain('daily_limit = models.'); // old field should be gone expect(src).not.toContain('monthly_limit = models.'); // old field should be gone }); it('User model default quota should be 600s/day and 6000s/month', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/accounts/models.py'), 'utf-8'); expect(src).toMatch(/daily_seconds_limit.*default=600/); expect(src).toMatch(/monthly_seconds_limit.*default=6000/); }); it('GenerationRecord should have seconds_consumed field', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/models.py'), 'utf-8'); expect(src).toContain('seconds_consumed'); expect(src).toMatch(/seconds_consumed\s*=\s*models\.FloatField/); }); it('Video generate view should use seconds-based quota check', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/views.py'), 'utf-8'); expect(src).toContain('daily_seconds_limit'); expect(src).toContain('monthly_seconds_limit'); expect(src).toContain("seconds_consumed"); // Check that quota enforcement uses Sum of seconds_consumed expect(src).toContain("Sum('seconds_consumed')"); }); it('Frontend Quota type should use seconds fields', () => { const src = readFileSync(resolve(ROOT, 'src/types/index.ts'), 'utf-8'); expect(src).toContain('daily_seconds_limit: number'); expect(src).toContain('daily_seconds_used: number'); expect(src).toContain('monthly_seconds_limit: number'); expect(src).toContain('monthly_seconds_used: number'); }); it('UserInfoBar should display quota in seconds format', () => { const src = readFileSync(resolve(ROOT, 'src/components/UserInfoBar.tsx'), 'utf-8'); // Should show seconds remaining, not calls remaining expect(src).toContain('daily_seconds_limit'); expect(src).toContain('daily_seconds_used'); expect(src).toMatch(/s\/.*s\(日\)/); // Format: Xs/Xs(日) }); it('Auth store should have quota state with seconds fields', () => { const src = readFileSync(resolve(ROOT, 'src/store/auth.ts'), 'utf-8'); expect(src).toContain('quota: Quota | null'); expect(src).toContain('fetchUserInfo'); // After login, should fetch quota info expect(src).toContain('get().fetchUserInfo()'); }); }); // ────────────────────────────────────────────── // 2. Admin Multi-Page Layout Verification // ────────────────────────────────────────────── describe('Phase 3: Admin Multi-Page Layout', () => { it('App.tsx should have nested admin routes under /admin', () => { const src = readFileSync(resolve(ROOT, 'src/App.tsx'), 'utf-8'); expect(src).toContain('path="/admin"'); expect(src).toContain('path="dashboard"'); expect(src).toContain('path="users"'); expect(src).toContain('path="records"'); expect(src).toContain('path="settings"'); // Should redirect /admin to /admin/dashboard expect(src).toContain('Navigate to="/admin/dashboard"'); }); it('Admin routes should require admin access (requireAdmin)', () => { const src = readFileSync(resolve(ROOT, 'src/App.tsx'), 'utf-8'); expect(src).toContain('requireAdmin'); }); it('AdminLayout should have sidebar with 4 navigation items', () => { const src = readFileSync(resolve(ROOT, 'src/pages/AdminLayout.tsx'), 'utf-8'); expect(src).toContain('/admin/dashboard'); expect(src).toContain('/admin/users'); expect(src).toContain('/admin/records'); expect(src).toContain('/admin/settings'); expect(src).toContain('仪表盘'); expect(src).toContain('用户管理'); expect(src).toContain('消费记录'); expect(src).toContain('系统设置'); }); it('AdminLayout should support sidebar collapse', () => { const src = readFileSync(resolve(ROOT, 'src/pages/AdminLayout.tsx'), 'utf-8'); expect(src).toContain('collapsed'); expect(src).toContain('setCollapsed'); }); it('AdminLayout should use NavLink for active state highlighting', () => { const src = readFileSync(resolve(ROOT, 'src/pages/AdminLayout.tsx'), 'utf-8'); expect(src).toContain('NavLink'); expect(src).toContain('isActive'); expect(src).toContain('navItemActive'); }); it('AdminLayout should have back-to-home button', () => { const src = readFileSync(resolve(ROOT, 'src/pages/AdminLayout.tsx'), 'utf-8'); expect(src).toContain('返回首页'); expect(src).toContain("navigate('/')"); }); it('AdminLayout should render child routes via Outlet', () => { const src = readFileSync(resolve(ROOT, 'src/pages/AdminLayout.tsx'), 'utf-8'); expect(src).toContain('Outlet'); }); }); // ────────────────────────────────────────────── // 3. Dashboard Page Verification // ────────────────────────────────────────────── describe('Phase 3: Dashboard Page', () => { it('should use ECharts for charts', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('echarts'); expect(src).toContain('ReactEChartsCore'); expect(src).toContain('LineChart'); expect(src).toContain('BarChart'); }); it('should display 4 stat cards (total users, new users, today seconds, month seconds)', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('总用户数'); expect(src).toContain('今日新增用户'); expect(src).toContain('今日消费秒数'); expect(src).toContain('本月消费秒数'); }); it('should show trend arrows with color coding', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); // Positive = green (↑), negative = red (↓) expect(src).toContain('positive'); expect(src).toContain('negative'); expect(src).toMatch(/card\.change\s*>=\s*0/); }); it('should render consumption trend line chart (30 days)', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('消费趋势'); expect(src).toContain("type: 'line'"); expect(src).toContain('daily_trend'); }); it('should render top users bar chart (horizontal)', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('用户消费排行'); expect(src).toContain("type: 'bar'"); expect(src).toContain('top_users'); }); it('should have mock data fallback for development', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('generateMockTrend'); expect(src).toContain('generateMockTopUsers'); }); it('should show skeleton loading state', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('skeleton'); expect(src).toContain('skeletonCard'); expect(src).toContain('skeletonChart'); }); it('should call adminApi.getStats for data', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('adminApi.getStats'); }); }); // ────────────────────────────────────────────── // 4. Users Management Page Verification // ────────────────────────────────────────────── describe('Phase 3: Users Management Page', () => { it('should render user table with correct columns', () => { const src = readFileSync(resolve(ROOT, 'src/pages/UsersPage.tsx'), 'utf-8'); expect(src).toContain('用户名'); expect(src).toContain('邮箱'); expect(src).toContain('注册时间'); expect(src).toContain('状态'); expect(src).toContain('日限额(秒)'); expect(src).toContain('月限额(秒)'); expect(src).toContain('今日消费(秒)'); expect(src).toContain('本月消费(秒)'); }); it('should support search and status filter', () => { const src = readFileSync(resolve(ROOT, 'src/pages/UsersPage.tsx'), 'utf-8'); expect(src).toContain('搜索用户名/邮箱'); expect(src).toContain('全部状态'); expect(src).toContain('statusFilter'); }); it('should have quota edit modal', () => { const src = readFileSync(resolve(ROOT, 'src/pages/UsersPage.tsx'), 'utf-8'); expect(src).toContain('编辑配额'); expect(src).toContain('每日秒数限额'); expect(src).toContain('每月秒数限额'); expect(src).toContain('handleSaveQuota'); }); it('should have user detail drawer', () => { const src = readFileSync(resolve(ROOT, 'src/pages/UsersPage.tsx'), 'utf-8'); expect(src).toContain('用户详情'); expect(src).toContain('drawerOpen'); expect(src).toContain('detailUser'); expect(src).toContain('近期消费记录'); }); it('should have enable/disable user toggle', () => { const src = readFileSync(resolve(ROOT, 'src/pages/UsersPage.tsx'), 'utf-8'); expect(src).toContain('handleToggleStatus'); expect(src).toContain('updateUserStatus'); }); it('should have pagination', () => { const src = readFileSync(resolve(ROOT, 'src/pages/UsersPage.tsx'), 'utf-8'); expect(src).toContain('totalPages'); expect(src).toContain('pagination'); expect(src).toContain('setPage'); }); }); // ────────────────────────────────────────────── // 5. Records Page Verification // ────────────────────────────────────────────── describe('Phase 3: Records Page', () => { it('should render records table with correct columns', () => { const src = readFileSync(resolve(ROOT, 'src/pages/RecordsPage.tsx'), 'utf-8'); expect(src).toContain('时间'); expect(src).toContain('用户名'); expect(src).toContain('消费秒数'); expect(src).toContain('视频描述'); expect(src).toContain('模式'); expect(src).toContain('状态'); }); it('should support date range filtering', () => { const src = readFileSync(resolve(ROOT, 'src/pages/RecordsPage.tsx'), 'utf-8'); expect(src).toContain('startDate'); expect(src).toContain('endDate'); expect(src).toContain('type="date"'); }); it('should support CSV export', () => { const src = readFileSync(resolve(ROOT, 'src/pages/RecordsPage.tsx'), 'utf-8'); expect(src).toContain('导出 CSV'); expect(src).toContain('handleExportCSV'); expect(src).toContain('text/csv'); expect(src).toContain('Blob'); }); it('CSV export should escape special characters to prevent injection', () => { const src = readFileSync(resolve(ROOT, 'src/pages/RecordsPage.tsx'), 'utf-8'); // Should escape double quotes and formula injection characters expect(src).toContain('replace(/"/g'); expect(src).toMatch(/replace\(.*\[=\+\\\-@\]/); }); it('should support username search', () => { const src = readFileSync(resolve(ROOT, 'src/pages/RecordsPage.tsx'), 'utf-8'); expect(src).toContain('按用户名搜索'); expect(src).toContain('search'); }); }); // ────────────────────────────────────────────── // 6. Settings Page Verification // ────────────────────────────────────────────── describe('Phase 3: Settings Page', () => { it('should have global quota settings form', () => { const src = readFileSync(resolve(ROOT, 'src/pages/SettingsPage.tsx'), 'utf-8'); expect(src).toContain('全局默认配额'); expect(src).toContain('默认每日限额 (秒)'); expect(src).toContain('默认每月限额 (秒)'); expect(src).toContain('保存配额设置'); }); it('should have announcement management', () => { const src = readFileSync(resolve(ROOT, 'src/pages/SettingsPage.tsx'), 'utf-8'); expect(src).toContain('系统公告'); expect(src).toContain('announcement_enabled'); expect(src).toContain('announcement'); expect(src).toContain('保存公告'); }); it('should have announcement toggle switch', () => { const src = readFileSync(resolve(ROOT, 'src/pages/SettingsPage.tsx'), 'utf-8'); expect(src).toContain('type="checkbox"'); expect(src).toContain('announcement_enabled'); }); it('should call adminApi.getSettings and adminApi.updateSettings', () => { const src = readFileSync(resolve(ROOT, 'src/pages/SettingsPage.tsx'), 'utf-8'); expect(src).toContain('adminApi.getSettings'); expect(src).toContain('adminApi.updateSettings'); }); }); // ────────────────────────────────────────────── // 7. Profile Page Verification // ────────────────────────────────────────────── describe('Phase 3: Profile Page', () => { it('should have consumption overview with gauge chart', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('GaugeChart'); expect(src).toContain("type: 'gauge'"); expect(src).toContain('消费概览'); }); it('should show daily and monthly quota progress bars', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('今日额度'); expect(src).toContain('本月额度'); expect(src).toContain('progressBar'); expect(src).toContain('progressFill'); }); it('should have consumption trend sparkline with 7d/30d toggle', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('消费趋势'); expect(src).toContain('近7天'); expect(src).toContain('近30天'); expect(src).toContain('trendPeriod'); }); it('should show warning banner when daily quota > 80%', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('warningBanner'); expect(src).toContain('dangerBanner'); expect(src).toMatch(/dailyPercent\s*>=\s*80/); expect(src).toMatch(/dailyPercent\s*>=\s*100/); }); it('should have consumption records list with load more', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('消费记录'); expect(src).toContain('加载更多'); expect(src).toContain('recordsPage'); }); it('should call profileApi for data', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('profileApi.getOverview'); expect(src).toContain('profileApi.getRecords'); }); it('should have back-to-home button', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('返回首页'); expect(src).toContain("navigate('/')"); }); it('should have logout functionality', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('退出'); expect(src).toContain('handleLogout'); }); }); // ────────────────────────────────────────────── // 8. Backend API Routes Verification // ────────────────────────────────────────────── describe('Phase 3: Backend API Routes', () => { it('should have all Phase 3 URL patterns', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/urls.py'), 'utf-8'); const requiredPaths = [ 'video/generate', 'admin/stats', 'admin/users', 'admin/records', 'admin/settings', 'profile/overview', 'profile/records', ]; for (const path of requiredPaths) { expect(src).toContain(path); } }); it('should have user quota and status management endpoints', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/urls.py'), 'utf-8'); expect(src).toContain('quota'); expect(src).toContain('status'); }); it('admin views should require IsAdminUser permission', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/views.py'), 'utf-8'); // All admin views should use IsAdminUser const adminViews = ['admin_stats_view', 'admin_users_list_view', 'admin_user_detail_view', 'admin_user_quota_view', 'admin_user_status_view', 'admin_records_view', 'admin_settings_view']; for (const view of adminViews) { // Find the view function and check it has IsAdminUser const viewRegex = new RegExp(`@permission_classes\\(\\[IsAdminUser\\]\\)\\s*\\ndef\\s+${view}`); expect(src).toMatch(viewRegex); } }); it('profile views should require IsAuthenticated permission', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/views.py'), 'utf-8'); const profileViews = ['profile_overview_view', 'profile_records_view']; for (const view of profileViews) { const viewRegex = new RegExp(`@permission_classes\\(\\[IsAuthenticated\\]\\)\\s*\\ndef\\s+${view}`); expect(src).toMatch(viewRegex); } }); }); // ────────────────────────────────────────────── // 9. QuotaConfig Model Verification // ────────────────────────────────────────────── describe('Phase 3: QuotaConfig Model (System Settings)', () => { it('should have default quota fields in seconds', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/models.py'), 'utf-8'); expect(src).toContain('default_daily_seconds_limit'); expect(src).toContain('default_monthly_seconds_limit'); }); it('should have announcement fields', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/models.py'), 'utf-8'); expect(src).toContain('announcement'); expect(src).toContain('announcement_enabled'); }); it('should enforce singleton pattern (pk=1)', () => { const src = readFileSync(resolve(ROOT, 'backend/apps/generation/models.py'), 'utf-8'); expect(src).toContain('self.pk = 1'); }); }); // ────────────────────────────────────────────── // 10. ProtectedRoute Admin Guard // ────────────────────────────────────────────── describe('Phase 3: ProtectedRoute Admin Guard', () => { it('should redirect non-admin users away from admin routes', () => { const src = readFileSync(resolve(ROOT, 'src/components/ProtectedRoute.tsx'), 'utf-8'); expect(src).toContain('requireAdmin'); expect(src).toContain('is_staff'); // Non-admin should be redirected to / expect(src).toContain('Navigate to="/"'); }); }); // ────────────────────────────────────────────── // 11. UserInfoBar Phase 3 Features // ────────────────────────────────────────────── describe('Phase 3: UserInfoBar Navigation', () => { it('should have profile link', () => { const src = readFileSync(resolve(ROOT, 'src/components/UserInfoBar.tsx'), 'utf-8'); expect(src).toContain('个人中心'); expect(src).toContain("'/profile'"); }); it('should show admin link only for staff users', () => { const src = readFileSync(resolve(ROOT, 'src/components/UserInfoBar.tsx'), 'utf-8'); expect(src).toContain('is_staff'); expect(src).toContain('管理后台'); expect(src).toContain("'/admin/dashboard'"); }); }); // ────────────────────────────────────────────── // 12. ECharts Integration // ────────────────────────────────────────────── describe('Phase 3: ECharts Integration', () => { it('package.json should include echarts dependencies', () => { const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')); expect(pkg.dependencies.echarts).toBeDefined(); expect(pkg.dependencies['echarts-for-react']).toBeDefined(); }); it('DashboardPage should use ECharts components', () => { const src = readFileSync(resolve(ROOT, 'src/pages/DashboardPage.tsx'), 'utf-8'); expect(src).toContain('echarts/core'); expect(src).toContain('echarts/charts'); expect(src).toContain('CanvasRenderer'); expect(src).toContain('TooltipComponent'); expect(src).toContain('DataZoomComponent'); }); it('ProfilePage should use ECharts for gauge and sparkline', () => { const src = readFileSync(resolve(ROOT, 'src/pages/ProfilePage.tsx'), 'utf-8'); expect(src).toContain('GaugeChart'); expect(src).toContain('LineChart'); expect(src).toContain('CanvasRenderer'); }); }); // ────────────────────────────────────────────── // 13. Frontend Route Configuration // ────────────────────────────────────────────── describe('Phase 3: Route Configuration', () => { it('should have /profile route', () => { const src = readFileSync(resolve(ROOT, 'src/App.tsx'), 'utf-8'); expect(src).toContain('path="/profile"'); expect(src).toContain('ProfilePage'); }); it('/profile should be protected', () => { const src = readFileSync(resolve(ROOT, 'src/App.tsx'), 'utf-8'); // ProfilePage should be wrapped in ProtectedRoute const profileSection = src.slice( src.indexOf('path="/profile"'), src.indexOf('path="/admin"') ); expect(profileSection).toContain('ProtectedRoute'); }); it('/admin should redirect to /admin/dashboard', () => { const src = readFileSync(resolve(ROOT, 'src/App.tsx'), 'utf-8'); expect(src).toContain('Navigate to="/admin/dashboard" replace'); }); it('unknown routes should redirect to /', () => { const src = readFileSync(resolve(ROOT, 'src/App.tsx'), 'utf-8'); expect(src).toContain('path="*"'); expect(src).toContain('Navigate to="/"'); }); });