- web/: React + Vite + TypeScript 前端 - backend/: Django + DRF + SimpleJWT 后端 - prototype/: HTML 设计原型 - docs/: PRD 和设计评审文档 - test: 单元测试 + E2E 极限测试
263 lines
9.8 KiB
TypeScript
263 lines
9.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock localStorage BEFORE anything imports auth store
|
|
// vi.hoisted runs before vi.mock and imports
|
|
const mockStorage: Record<string, string> = {};
|
|
const mockLocalStorage = {
|
|
getItem: vi.fn((key: string) => mockStorage[key] || null),
|
|
setItem: vi.fn((key: string, value: string) => { mockStorage[key] = value; }),
|
|
removeItem: vi.fn((key: string) => { delete mockStorage[key]; }),
|
|
clear: vi.fn(() => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }),
|
|
get length() { return Object.keys(mockStorage).length; },
|
|
key: vi.fn((i: number) => Object.keys(mockStorage)[i] || null),
|
|
};
|
|
|
|
vi.stubGlobal('localStorage', mockLocalStorage);
|
|
|
|
// Mock the api module before importing the store
|
|
vi.mock('../../src/lib/api', () => ({
|
|
authApi: {
|
|
login: vi.fn(),
|
|
register: vi.fn(),
|
|
refreshToken: vi.fn(),
|
|
getMe: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { useAuthStore } from '../../src/store/auth';
|
|
import { authApi } from '../../src/lib/api';
|
|
|
|
const mockedAuthApi = vi.mocked(authApi);
|
|
|
|
describe('Auth Store', () => {
|
|
beforeEach(() => {
|
|
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
|
|
vi.clearAllMocks();
|
|
// Reset store to initial state
|
|
useAuthStore.setState({
|
|
user: null,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
isAuthenticated: false,
|
|
isLoading: true,
|
|
quota: null,
|
|
});
|
|
});
|
|
|
|
describe('Initial state', () => {
|
|
it('should have correct default values', () => {
|
|
const state = useAuthStore.getState();
|
|
expect(state.user).toBeNull();
|
|
expect(state.isAuthenticated).toBe(false);
|
|
expect(state.isLoading).toBe(true);
|
|
expect(state.quota).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('should login successfully and store tokens', async () => {
|
|
mockedAuthApi.login.mockResolvedValue({
|
|
data: {
|
|
user: { id: 1, username: 'testuser', email: 'test@test.com', is_staff: false },
|
|
tokens: { access: 'access-token-123', refresh: 'refresh-token-456' },
|
|
},
|
|
} as any);
|
|
mockedAuthApi.getMe.mockResolvedValue({
|
|
data: {
|
|
id: 1, username: 'testuser', email: 'test@test.com', is_staff: false,
|
|
quota: { daily_limit: 50, daily_used: 5, monthly_limit: 500, monthly_used: 100 },
|
|
},
|
|
} as any);
|
|
|
|
await useAuthStore.getState().login('testuser', 'password123');
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.user?.username).toBe('testuser');
|
|
expect(state.isAuthenticated).toBe(true);
|
|
expect(state.accessToken).toBe('access-token-123');
|
|
expect(state.refreshToken).toBe('refresh-token-456');
|
|
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('access_token', 'access-token-123');
|
|
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-token-456');
|
|
});
|
|
|
|
it('should call authApi.login with correct args', async () => {
|
|
mockedAuthApi.login.mockResolvedValue({
|
|
data: {
|
|
user: { id: 1, username: 'u', email: 'e', is_staff: false },
|
|
tokens: { access: 'a', refresh: 'r' },
|
|
},
|
|
} as any);
|
|
mockedAuthApi.getMe.mockResolvedValue({
|
|
data: { id: 1, username: 'u', email: 'e', is_staff: false, quota: { daily_limit: 50, daily_used: 0, monthly_limit: 500, monthly_used: 0 } },
|
|
} as any);
|
|
|
|
await useAuthStore.getState().login('myuser', 'mypass');
|
|
expect(mockedAuthApi.login).toHaveBeenCalledWith('myuser', 'mypass');
|
|
});
|
|
|
|
it('should throw on login failure', async () => {
|
|
mockedAuthApi.login.mockRejectedValue(new Error('Invalid credentials'));
|
|
|
|
await expect(useAuthStore.getState().login('bad', 'cred')).rejects.toThrow('Invalid credentials');
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
});
|
|
|
|
it('should fetch user info (quota) after login', async () => {
|
|
mockedAuthApi.login.mockResolvedValue({
|
|
data: {
|
|
user: { id: 1, username: 'u', email: 'e', is_staff: false },
|
|
tokens: { access: 'a', refresh: 'r' },
|
|
},
|
|
} as any);
|
|
mockedAuthApi.getMe.mockResolvedValue({
|
|
data: {
|
|
id: 1, username: 'u', email: 'e', is_staff: false,
|
|
quota: { daily_limit: 50, daily_used: 10, monthly_limit: 500, monthly_used: 200 },
|
|
},
|
|
} as any);
|
|
|
|
await useAuthStore.getState().login('u', 'p');
|
|
expect(mockedAuthApi.getMe).toHaveBeenCalled();
|
|
expect(useAuthStore.getState().quota?.daily_used).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('register', () => {
|
|
it('should register and auto-login', async () => {
|
|
mockedAuthApi.register.mockResolvedValue({
|
|
data: {
|
|
user: { id: 2, username: 'newuser', email: 'new@test.com', is_staff: false },
|
|
tokens: { access: 'new-access', refresh: 'new-refresh' },
|
|
},
|
|
} as any);
|
|
mockedAuthApi.getMe.mockResolvedValue({
|
|
data: {
|
|
id: 2, username: 'newuser', email: 'new@test.com', is_staff: false,
|
|
quota: { daily_limit: 50, daily_used: 0, monthly_limit: 500, monthly_used: 0 },
|
|
},
|
|
} as any);
|
|
|
|
await useAuthStore.getState().register('newuser', 'new@test.com', 'pass123');
|
|
|
|
expect(mockedAuthApi.register).toHaveBeenCalledWith('newuser', 'new@test.com', 'pass123');
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
|
expect(useAuthStore.getState().user?.username).toBe('newuser');
|
|
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('access_token', 'new-access');
|
|
});
|
|
|
|
it('should throw on register failure', async () => {
|
|
mockedAuthApi.register.mockRejectedValue(new Error('Username taken'));
|
|
await expect(useAuthStore.getState().register('taken', 'e@e.com', 'p')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('should clear all auth state and localStorage', () => {
|
|
useAuthStore.setState({
|
|
user: { id: 1, username: 'u', email: 'e', is_staff: false },
|
|
accessToken: 'tok',
|
|
refreshToken: 'ref',
|
|
isAuthenticated: true,
|
|
quota: { daily_limit: 50, daily_used: 5, monthly_limit: 500, monthly_used: 100 },
|
|
});
|
|
|
|
useAuthStore.getState().logout();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.user).toBeNull();
|
|
expect(state.accessToken).toBeNull();
|
|
expect(state.refreshToken).toBeNull();
|
|
expect(state.isAuthenticated).toBe(false);
|
|
expect(state.quota).toBeNull();
|
|
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('access_token');
|
|
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('refresh_token');
|
|
});
|
|
});
|
|
|
|
describe('refreshAccessToken', () => {
|
|
it('should refresh access token using refresh token', async () => {
|
|
useAuthStore.setState({ refreshToken: 'valid-refresh' });
|
|
mockedAuthApi.refreshToken.mockResolvedValue({ data: { access: 'new-access-token' } } as any);
|
|
|
|
await useAuthStore.getState().refreshAccessToken();
|
|
|
|
expect(mockedAuthApi.refreshToken).toHaveBeenCalledWith('valid-refresh');
|
|
expect(useAuthStore.getState().accessToken).toBe('new-access-token');
|
|
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('access_token', 'new-access-token');
|
|
});
|
|
|
|
it('should throw when no refresh token exists', async () => {
|
|
useAuthStore.setState({ refreshToken: null });
|
|
await expect(useAuthStore.getState().refreshAccessToken()).rejects.toThrow('No refresh token');
|
|
});
|
|
});
|
|
|
|
describe('fetchUserInfo', () => {
|
|
it('should fetch and set user info and quota', async () => {
|
|
mockedAuthApi.getMe.mockResolvedValue({
|
|
data: {
|
|
id: 1, username: 'test', email: 'test@test.com', is_staff: true,
|
|
quota: { daily_limit: 100, daily_used: 25, monthly_limit: 1000, monthly_used: 300 },
|
|
},
|
|
} as any);
|
|
|
|
await useAuthStore.getState().fetchUserInfo();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.user?.username).toBe('test');
|
|
expect(state.user?.is_staff).toBe(true);
|
|
expect(state.isAuthenticated).toBe(true);
|
|
expect(state.quota?.daily_limit).toBe(100);
|
|
expect(state.quota?.daily_used).toBe(25);
|
|
});
|
|
|
|
it('should logout on API failure (invalid token)', async () => {
|
|
useAuthStore.setState({
|
|
user: { id: 1, username: 'u', email: 'e', is_staff: false },
|
|
isAuthenticated: true,
|
|
});
|
|
mockedAuthApi.getMe.mockRejectedValue(new Error('401'));
|
|
|
|
await useAuthStore.getState().fetchUserInfo();
|
|
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
expect(useAuthStore.getState().user).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('initialize', () => {
|
|
it('should set isLoading to false after initialization with no token', async () => {
|
|
mockLocalStorage.getItem.mockReturnValue(null);
|
|
await useAuthStore.getState().initialize();
|
|
|
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
|
expect(mockedAuthApi.getMe).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fetch user info when token exists in localStorage', async () => {
|
|
mockLocalStorage.getItem.mockReturnValue('stored-access-token');
|
|
mockedAuthApi.getMe.mockResolvedValue({
|
|
data: {
|
|
id: 1, username: 'stored', email: 's@t.com', is_staff: false,
|
|
quota: { daily_limit: 50, daily_used: 0, monthly_limit: 500, monthly_used: 0 },
|
|
},
|
|
} as any);
|
|
|
|
await useAuthStore.getState().initialize();
|
|
|
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
|
expect(useAuthStore.getState().user?.username).toBe('stored');
|
|
});
|
|
|
|
it('should logout if token is invalid during initialization', async () => {
|
|
mockLocalStorage.getItem.mockReturnValue('expired-token');
|
|
mockedAuthApi.getMe.mockRejectedValue(new Error('401'));
|
|
|
|
await useAuthStore.getState().initialize();
|
|
|
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
});
|
|
});
|
|
});
|