import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { mount, flushPromises } from '@vue/test-utils' import { nextTick } from 'vue' import { message } from 'ant-design-vue' import { ApiError } from '@/types/api' import { useUserStore } from '@/stores/user' // jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要 Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }) vi.mock('@/utils/request', () => ({ api: { get: vi.fn(), post: vi.fn(), }, })) const mockPush = vi.fn() const mockRoute = { query: {} } vi.mock('vue-router', () => ({ useRouter: () => ({ push: mockPush }), useRoute: () => mockRoute, })) // Stub Brand component to avoid asset loading vi.mock('@/components/Brand.vue', () => ({ default: { template: '
' }, })) describe('Login Page', () => { let wrapper: ReturnType beforeEach(() => { setActivePinia(createPinia()) localStorage.clear() vi.restoreAllMocks() mockPush.mockReset() mockRoute.query = {} vi.useFakeTimers() }) afterEach(() => { wrapper?.unmount() vi.useRealTimers() }) async function mountPage() { const { default: LoginPage } = await import('../login.vue') wrapper = mount(LoginPage, { attachTo: document.body, global: { stubs: { UserOutlined: { template: '' }, LockOutlined: { template: '' }, }, }, }) await flushPromises() await nextTick() return wrapper } it('renders login form with username, password inputs and submit button', async () => { await mountPage() const html = wrapper.html() // 验证输入框存在 expect(html).toContain('请输入用户名') expect(html).toContain('请输入密码') // 验证提交按钮存在 expect(html).toContain('登录') }) // 辅助:填写表单并提交,绕过 Ant Design 表单验证 async function submitLogin( fields: { username: string; password: string; remember?: boolean }, ) { const vm = wrapper.getCurrentComponent().setupState vm.formState.username = fields.username vm.formState.password = fields.password if (fields.remember !== undefined) vm.formState.remember = fields.remember vm.formRef = { validate: vi.fn().mockResolvedValue(true) } await vm.handleSubmit() await flushPromises() } it('calls /api/v1/login, sets token/user and redirects to /', async () => { const { api } = await import('@/utils/request') const mockResponse = { access_token: 'a.b.c', refresh_token: 'r.r.r', user: { id: 1, username: 'admin', email: 'a@b.com', role: 'admin', status: 1 }, } vi.mocked(api.post).mockResolvedValueOnce(mockResponse) await mountPage() const store = useUserStore() await submitLogin({ username: 'admin', password: 'password' }) expect(api.post).toHaveBeenCalledWith('/api/v1/login', { username: 'admin', password: 'password', }) expect(store.token).toBe('a.b.c') expect(store.user).toEqual(mockResponse.user) vi.advanceTimersByTime(500) expect(mockPush).toHaveBeenCalledWith('/') }) it('passes remember=false to setToken when unchecked', async () => { const { api } = await import('@/utils/request') vi.mocked(api.post).mockResolvedValueOnce({ access_token: 'a.b.c', refresh_token: 'r.r.r', user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, }) await mountPage() await submitLogin({ username: 'user', password: 'pass', remember: false }) expect(localStorage.getItem('access_token')).toBeNull() expect(localStorage.getItem('user')).toBeNull() }) it('redirects to safe path from query param', async () => { const { api } = await import('@/utils/request') vi.mocked(api.post).mockResolvedValueOnce({ access_token: 'a.b.c', refresh_token: 'r.r.r', user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, }) mockRoute.query = { redirect: '/dashboard' } await mountPage() await submitLogin({ username: 'user', password: 'pass' }) vi.advanceTimersByTime(500) expect(mockPush).toHaveBeenCalledWith('/dashboard') }) it('blocks //evil redirect and falls back to /', async () => { const { api } = await import('@/utils/request') vi.mocked(api.post).mockResolvedValueOnce({ access_token: 'a.b.c', refresh_token: 'r.r.r', user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, }) mockRoute.query = { redirect: '//evil.com' } await mountPage() await submitLogin({ username: 'user', password: 'pass' }) vi.advanceTimersByTime(500) expect(mockPush).toHaveBeenCalledWith('/') }) it('blocks http:// redirect and falls back to /', async () => { const { api } = await import('@/utils/request') vi.mocked(api.post).mockResolvedValueOnce({ access_token: 'a.b.c', refresh_token: 'r.r.r', user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, }) mockRoute.query = { redirect: 'http://evil.com' } await mountPage() await submitLogin({ username: 'user', password: 'pass' }) vi.advanceTimersByTime(500) expect(mockPush).toHaveBeenCalledWith('/') }) it('shows error message on ApiError', async () => { const { api } = await import('@/utils/request') vi.mocked(api.post).mockRejectedValueOnce(new ApiError('用户名或密码错误', 401)) const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any) await mountPage() await submitLogin({ username: 'user', password: 'wrong' }) expect(errorSpy).toHaveBeenCalledWith('用户名或密码错误') errorSpy.mockRestore() }) it('shows network error message on generic error', async () => { const { api } = await import('@/utils/request') vi.mocked(api.post).mockRejectedValueOnce(new Error('Network failure')) const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any) await mountPage() await submitLogin({ username: 'user', password: 'pass' }) expect(errorSpy).toHaveBeenCalledWith('登录失败,请检查网络连接') errorSpy.mockRestore() }) it('navigates to register page', async () => { await mountPage() const vm = wrapper.getCurrentComponent().setupState vm.goToRegister() expect(mockPush).toHaveBeenCalledWith('/register') }) })