- web/: React + Vite + TypeScript 前端 - backend/: Django + DRF + SimpleJWT 后端 - prototype/: HTML 设计原型 - docs/: PRD 和设计评审文档 - test: 单元测试 + E2E 极限测试
292 lines
9.5 KiB
TypeScript
292 lines
9.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen } from '@testing-library/react';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
|
|
// Mock the auth store
|
|
const mockAuthState = {
|
|
user: null as any,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
quota: null as any,
|
|
logout: vi.fn(),
|
|
initialize: vi.fn(),
|
|
};
|
|
|
|
vi.mock('../../src/store/auth', () => ({
|
|
useAuthStore: (selector: (s: any) => any) => selector(mockAuthState),
|
|
}));
|
|
|
|
describe('ProtectedRoute', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockAuthState.user = null;
|
|
mockAuthState.isAuthenticated = false;
|
|
mockAuthState.isLoading = false;
|
|
mockAuthState.quota = null;
|
|
});
|
|
|
|
it('should show loading when isLoading is true', async () => {
|
|
mockAuthState.isLoading = true;
|
|
const { ProtectedRoute } = await import('../../src/components/ProtectedRoute');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<ProtectedRoute><div>Protected Content</div></ProtectedRoute>
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText('加载中...')).toBeInTheDocument();
|
|
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should redirect to /login when not authenticated', async () => {
|
|
mockAuthState.isAuthenticated = false;
|
|
mockAuthState.isLoading = false;
|
|
const { ProtectedRoute } = await import('../../src/components/ProtectedRoute');
|
|
|
|
const { container } = render(
|
|
<MemoryRouter initialEntries={['/']}>
|
|
<ProtectedRoute><div>Protected Content</div></ProtectedRoute>
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should render children when authenticated', async () => {
|
|
mockAuthState.isAuthenticated = true;
|
|
mockAuthState.isLoading = false;
|
|
mockAuthState.user = { id: 1, username: 'test', email: 'e', is_staff: false };
|
|
const { ProtectedRoute } = await import('../../src/components/ProtectedRoute');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<ProtectedRoute><div>Protected Content</div></ProtectedRoute>
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should redirect non-admin from admin routes', async () => {
|
|
mockAuthState.isAuthenticated = true;
|
|
mockAuthState.isLoading = false;
|
|
mockAuthState.user = { id: 1, username: 'test', email: 'e', is_staff: false };
|
|
const { ProtectedRoute } = await import('../../src/components/ProtectedRoute');
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/admin/dashboard']}>
|
|
<ProtectedRoute requireAdmin><div>Admin Content</div></ProtectedRoute>
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.queryByText('Admin Content')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should render admin content for staff users', async () => {
|
|
mockAuthState.isAuthenticated = true;
|
|
mockAuthState.isLoading = false;
|
|
mockAuthState.user = { id: 1, username: 'admin', email: 'e', is_staff: true };
|
|
const { ProtectedRoute } = await import('../../src/components/ProtectedRoute');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<ProtectedRoute requireAdmin><div>Admin Content</div></ProtectedRoute>
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText('Admin Content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('UserInfoBar', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockAuthState.user = null;
|
|
mockAuthState.isAuthenticated = false;
|
|
mockAuthState.isLoading = false;
|
|
mockAuthState.quota = null;
|
|
mockAuthState.logout = vi.fn();
|
|
});
|
|
|
|
it('should render nothing when user is null', async () => {
|
|
mockAuthState.user = null;
|
|
const { UserInfoBar } = await import('../../src/components/UserInfoBar');
|
|
|
|
const { container } = render(
|
|
<MemoryRouter>
|
|
<UserInfoBar />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(container.children.length).toBe(0);
|
|
});
|
|
|
|
it('should display username and avatar', async () => {
|
|
mockAuthState.user = { id: 1, username: 'johndoe', email: 'j@t.com', is_staff: false };
|
|
const { UserInfoBar } = await import('../../src/components/UserInfoBar');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<UserInfoBar />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText('johndoe')).toBeInTheDocument();
|
|
expect(screen.getByText('J')).toBeInTheDocument(); // avatar letter
|
|
});
|
|
|
|
it('should display quota information in seconds (Phase 3)', async () => {
|
|
mockAuthState.user = { id: 1, username: 'test', email: 'e', is_staff: false };
|
|
mockAuthState.quota = { daily_seconds_limit: 600, daily_seconds_used: 100, monthly_seconds_limit: 6000, monthly_seconds_used: 1000 };
|
|
const { UserInfoBar } = await import('../../src/components/UserInfoBar');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<UserInfoBar />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Phase 3: quota display shows seconds format: "剩余: Xs/Xs(日)"
|
|
expect(screen.getByText(/剩余/)).toBeInTheDocument();
|
|
expect(screen.getByText(/500s\/600s/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show admin button for staff users', async () => {
|
|
mockAuthState.user = { id: 1, username: 'admin', email: 'e', is_staff: true };
|
|
const { UserInfoBar } = await import('../../src/components/UserInfoBar');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<UserInfoBar />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText('管理后台')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should NOT show admin button for non-staff users', async () => {
|
|
mockAuthState.user = { id: 1, username: 'regular', email: 'e', is_staff: false };
|
|
const { UserInfoBar } = await import('../../src/components/UserInfoBar');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<UserInfoBar />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.queryByText('管理后台')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should show logout button', async () => {
|
|
mockAuthState.user = { id: 1, username: 'test', email: 'e', is_staff: false };
|
|
const { UserInfoBar } = await import('../../src/components/UserInfoBar');
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<UserInfoBar />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText('退出')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Phase 2 Type Definitions', () => {
|
|
it('should have User interface with required fields', async () => {
|
|
const types = await import('../../src/types/index');
|
|
// Type verification through instantiation
|
|
const user: typeof types extends { User: infer U } ? never : any = {
|
|
id: 1, username: 'test', email: 'test@test.com', is_staff: false,
|
|
};
|
|
expect(user).toBeDefined();
|
|
});
|
|
|
|
it('should have Quota interface with seconds-based fields (Phase 3)', async () => {
|
|
const quota = {
|
|
daily_seconds_limit: 600,
|
|
daily_seconds_used: 100,
|
|
monthly_seconds_limit: 6000,
|
|
monthly_seconds_used: 1000,
|
|
};
|
|
expect(quota.daily_seconds_limit).toBe(600);
|
|
expect(quota.monthly_seconds_limit).toBe(6000);
|
|
});
|
|
|
|
it('should have AuthTokens interface', async () => {
|
|
const tokens = { access: 'test', refresh: 'test' };
|
|
expect(tokens.access).toBeDefined();
|
|
expect(tokens.refresh).toBeDefined();
|
|
});
|
|
|
|
it('should have AdminStats interface with seconds fields (Phase 3)', async () => {
|
|
const stats = {
|
|
total_users: 100,
|
|
new_users_today: 5,
|
|
seconds_consumed_today: 4560,
|
|
seconds_consumed_this_month: 89010,
|
|
today_change_percent: -5.0,
|
|
month_change_percent: 8.0,
|
|
daily_trend: [{ date: '2026-03-01', seconds: 1200 }],
|
|
top_users: [{ user_id: 1, username: 'alice', seconds_consumed: 2340 }],
|
|
};
|
|
expect(stats.total_users).toBe(100);
|
|
expect(stats.daily_trend).toHaveLength(1);
|
|
expect(stats.daily_trend[0].seconds).toBe(1200);
|
|
expect(stats.top_users[0].seconds_consumed).toBe(2340);
|
|
});
|
|
|
|
it('should have AdminUser interface with seconds-based quota (Phase 3)', async () => {
|
|
const user = {
|
|
id: 1,
|
|
username: 'test',
|
|
email: 'test@test.com',
|
|
is_active: true,
|
|
date_joined: '2026-03-01',
|
|
daily_seconds_limit: 600,
|
|
monthly_seconds_limit: 6000,
|
|
seconds_today: 120,
|
|
seconds_this_month: 3500,
|
|
};
|
|
expect(user.daily_seconds_limit).toBe(600);
|
|
expect(user.monthly_seconds_limit).toBe(6000);
|
|
});
|
|
|
|
it('should have ProfileOverview interface (Phase 3)', async () => {
|
|
const overview = {
|
|
daily_seconds_limit: 600,
|
|
daily_seconds_used: 120,
|
|
monthly_seconds_limit: 6000,
|
|
monthly_seconds_used: 3500,
|
|
total_seconds_used: 15000,
|
|
daily_trend: [{ date: '2026-03-01', seconds: 45 }],
|
|
};
|
|
expect(overview.daily_seconds_limit).toBe(600);
|
|
expect(overview.daily_trend).toHaveLength(1);
|
|
});
|
|
|
|
it('should have SystemSettings interface (Phase 3)', async () => {
|
|
const settings = {
|
|
default_daily_seconds_limit: 600,
|
|
default_monthly_seconds_limit: 6000,
|
|
announcement: 'Test announcement',
|
|
announcement_enabled: true,
|
|
};
|
|
expect(settings.default_daily_seconds_limit).toBe(600);
|
|
expect(settings.announcement_enabled).toBe(true);
|
|
});
|
|
|
|
it('UploadedFile type should not include audio', async () => {
|
|
// Verify audio type was removed from UploadedFile (fix from previous sessions)
|
|
const { readFileSync } = await import('fs');
|
|
const typesSrc = readFileSync(
|
|
'/Users/maidong/Desktop/zyc/研究openclaw/视频生成平台/jimeng-clone/src/types/index.ts',
|
|
'utf-8'
|
|
);
|
|
// UploadedFile.type should only be 'image' | 'video'
|
|
const uploadedFileMatch = typesSrc.match(/interface UploadedFile[\s\S]*?type:\s*(.+?);/);
|
|
expect(uploadedFileMatch).toBeTruthy();
|
|
expect(uploadedFileMatch![1]).not.toContain('audio');
|
|
});
|
|
});
|