fix login and register
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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: '<div class="brand-stub" />' },
|
||||
}))
|
||||
|
||||
describe('Login Page', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
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: '<span />' },
|
||||
LockOutlined: { template: '<span />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
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('登录')
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
// 直接调用 handleSubmit 来绕过表单验证
|
||||
// 通过组件 vm 访问 setup 暴露的方法
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'admin'
|
||||
vm.formState.password = 'password'
|
||||
vm.formRef = { value: { validate: vi.fn().mockResolvedValue(true) } }
|
||||
// mock formRef.validate
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'user'
|
||||
vm.formState.password = 'pass'
|
||||
vm.formState.remember = false
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'user'
|
||||
vm.formState.password = 'pass'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'user'
|
||||
vm.formState.password = 'pass'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'user'
|
||||
vm.formState.password = 'pass'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'user'
|
||||
vm.formState.password = 'wrong'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'user'
|
||||
vm.formState.password = 'pass'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('登录失败,请检查网络连接')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('navigates to register page', async () => {
|
||||
await mountPage()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.goToRegister()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/register')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
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()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/Brand.vue', () => ({
|
||||
default: { template: '<div class="brand-stub" />' },
|
||||
}))
|
||||
|
||||
describe('Register Page', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
mockPush.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function mountPage() {
|
||||
const { default: RegisterPage } = await import('../register.vue')
|
||||
|
||||
wrapper = mount(RegisterPage, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
stubs: {
|
||||
UserOutlined: { template: '<span />' },
|
||||
LockOutlined: { template: '<span />' },
|
||||
MailOutlined: { template: '<span />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('renders register form with all fields and submit button', async () => {
|
||||
await mountPage()
|
||||
const html = wrapper.html()
|
||||
// 验证所有输入框的 placeholder 存在
|
||||
expect(html).toContain('请输入用户名')
|
||||
expect(html).toContain('请输入邮箱地址')
|
||||
expect(html).toContain('请输入密码')
|
||||
expect(html).toContain('请再次输入密码')
|
||||
// 验证注册按钮存在
|
||||
expect(html).toContain('注册')
|
||||
})
|
||||
|
||||
it('calls /api/v1/register and redirects to /login on success without token', async () => {
|
||||
const { api } = await import('@/utils/request')
|
||||
vi.mocked(api.post).mockResolvedValueOnce({})
|
||||
const successSpy = vi.spyOn(message, 'success').mockImplementation(() => ({}) as any)
|
||||
|
||||
await mountPage()
|
||||
const store = useUserStore()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'newuser'
|
||||
vm.formState.email = 'new@test.com'
|
||||
vm.formState.password = 'password123'
|
||||
vm.formState.confirmPassword = 'password123'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/api/v1/register', {
|
||||
username: 'newuser',
|
||||
email: 'new@test.com',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(store.token).toBeNull()
|
||||
expect(successSpy).toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1500)
|
||||
expect(mockPush).toHaveBeenCalledWith('/login')
|
||||
successSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sets token and user when backend returns them on register', async () => {
|
||||
const { api } = await import('@/utils/request')
|
||||
const mockResponse = {
|
||||
access_token: 'a.b.c',
|
||||
refresh_token: 'r.r.r',
|
||||
user: { id: 2, username: 'newuser', email: 'new@test.com', role: 'user', status: 1 },
|
||||
}
|
||||
vi.mocked(api.post).mockResolvedValueOnce(mockResponse)
|
||||
vi.spyOn(message, 'success').mockImplementation(() => ({}) as any)
|
||||
|
||||
await mountPage()
|
||||
const store = useUserStore()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'newuser'
|
||||
vm.formState.email = 'new@test.com'
|
||||
vm.formState.password = 'password123'
|
||||
vm.formState.confirmPassword = 'password123'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
expect(store.token).toBe('a.b.c')
|
||||
expect(store.user).toEqual(mockResponse.user)
|
||||
|
||||
vi.advanceTimersByTime(1500)
|
||||
expect(mockPush).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('shows error message on ApiError', async () => {
|
||||
const { api } = await import('@/utils/request')
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new ApiError('用户名已存在', 409))
|
||||
const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any)
|
||||
|
||||
await mountPage()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'existing'
|
||||
vm.formState.email = 'e@test.com'
|
||||
vm.formState.password = 'password123'
|
||||
vm.formState.confirmPassword = 'password123'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.formState.username = 'user'
|
||||
vm.formState.email = 'u@test.com'
|
||||
vm.formState.password = 'password123'
|
||||
vm.formState.confirmPassword = 'password123'
|
||||
vm.formRef = { validate: vi.fn().mockResolvedValue(true) }
|
||||
await vm.handleSubmit()
|
||||
await flushPromises()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('注册失败,请检查网络连接')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('navigates to login page', async () => {
|
||||
await mountPage()
|
||||
const vm = wrapper.getCurrentComponent().setupState
|
||||
vm.goToLogin()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@ const handleSubmit = async () => {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
user: UserInfo
|
||||
}>('/api/auth/login', {
|
||||
}>('/api/v1/login', {
|
||||
username: formState.username,
|
||||
password: formState.password,
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ const handleSubmit = async () => {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
user?: UserInfo
|
||||
}>('/api/auth/register', {
|
||||
}>('/api/v1/register', {
|
||||
username: formState.username,
|
||||
email: formState.email,
|
||||
password: formState.password,
|
||||
|
||||
Reference in New Issue
Block a user