feat: 增强情绪博物馆项目功能 - 新增用户评论和帖子功能,优化前端架构和WebSocket通信 - 更新文档和部署配置
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 认证功能 E2E 测试
|
||||
*/
|
||||
|
||||
describe('Authentication', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
describe('Login', () => {
|
||||
it('should redirect to login page when not authenticated', () => {
|
||||
cy.url().should('include', '/auth/login')
|
||||
cy.shouldBeVisible('[data-cy=login-form]')
|
||||
})
|
||||
|
||||
it('should login with valid credentials', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
// 填写登录表单
|
||||
cy.get('[data-cy=username-input]').type(Cypress.env('testUser').username)
|
||||
cy.get('[data-cy=password-input]').type(Cypress.env('testUser').password)
|
||||
|
||||
// 点击登录按钮
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 等待登录完成
|
||||
cy.wait('@login')
|
||||
|
||||
// 验证登录成功
|
||||
cy.url().should('not.include', '/auth/login')
|
||||
cy.shouldBeVisible('[data-cy=user-menu]')
|
||||
cy.shouldHaveLocalStorage('auth_token')
|
||||
})
|
||||
|
||||
it('should show error with invalid credentials', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
// 模拟登录失败
|
||||
cy.intercept('POST', '/api/auth/login', {
|
||||
statusCode: 401,
|
||||
body: { message: '用户名或密码错误' }
|
||||
}).as('loginFailed')
|
||||
|
||||
// 填写错误凭据
|
||||
cy.get('[data-cy=username-input]').type('wronguser')
|
||||
cy.get('[data-cy=password-input]').type('wrongpass')
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 验证错误消息
|
||||
cy.wait('@loginFailed')
|
||||
cy.shouldShowError('用户名或密码错误')
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
|
||||
it('should validate required fields', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
// 尝试提交空表单
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 验证验证消息
|
||||
cy.get('[data-cy=username-input]').should('have.class', 'error')
|
||||
cy.get('[data-cy=password-input]').should('have.class', 'error')
|
||||
})
|
||||
|
||||
it('should toggle password visibility', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
cy.get('[data-cy=password-input]').should('have.attr', 'type', 'password')
|
||||
cy.get('[data-cy=password-toggle]').click()
|
||||
cy.get('[data-cy=password-input]').should('have.attr', 'type', 'text')
|
||||
})
|
||||
|
||||
it('should remember login state', () => {
|
||||
// 登录
|
||||
cy.login()
|
||||
|
||||
// 刷新页面
|
||||
cy.reload()
|
||||
|
||||
// 验证仍然登录
|
||||
cy.shouldBeVisible('[data-cy=user-menu]')
|
||||
cy.url().should('not.include', '/auth/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Register', () => {
|
||||
it('should register new user successfully', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
// 模拟注册成功
|
||||
cy.intercept('POST', '/api/auth/register', {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
token: 'new-token',
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'newuser',
|
||||
email: 'new@example.com'
|
||||
}
|
||||
}
|
||||
}).as('register')
|
||||
|
||||
// 填写注册表单
|
||||
cy.get('[data-cy=username-input]').type('newuser')
|
||||
cy.get('[data-cy=email-input]').type('new@example.com')
|
||||
cy.get('[data-cy=password-input]').type('password123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('password123')
|
||||
cy.get('[data-cy=agree-terms]').check()
|
||||
|
||||
// 提交注册
|
||||
cy.get('[data-cy=register-button]').click()
|
||||
|
||||
// 验证注册成功
|
||||
cy.wait('@register')
|
||||
cy.url().should('not.include', '/auth/register')
|
||||
cy.shouldShowSuccess('注册成功')
|
||||
})
|
||||
|
||||
it('should validate email format', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
cy.get('[data-cy=email-input]').type('invalid-email')
|
||||
cy.get('[data-cy=username-input]').click() // 触发验证
|
||||
|
||||
cy.get('[data-cy=email-input]').should('have.class', 'error')
|
||||
cy.shouldContainText('[data-cy=email-error]', '邮箱格式不正确')
|
||||
})
|
||||
|
||||
it('should validate password strength', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
// 测试弱密码
|
||||
cy.get('[data-cy=password-input]').type('123')
|
||||
cy.get('[data-cy=username-input]').click()
|
||||
|
||||
cy.shouldContainText('[data-cy=password-strength]', '弱')
|
||||
|
||||
// 测试强密码
|
||||
cy.get('[data-cy=password-input]').clear().type('StrongPass123!')
|
||||
cy.shouldContainText('[data-cy=password-strength]', '强')
|
||||
})
|
||||
|
||||
it('should validate password confirmation', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
cy.get('[data-cy=password-input]').type('password123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('different')
|
||||
cy.get('[data-cy=username-input]').click()
|
||||
|
||||
cy.shouldContainText('[data-cy=confirm-password-error]', '两次输入的密码不一致')
|
||||
})
|
||||
|
||||
it('should require terms agreement', () => {
|
||||
cy.visit('/auth/register')
|
||||
|
||||
// 填写所有字段但不同意条款
|
||||
cy.get('[data-cy=username-input]').type('newuser')
|
||||
cy.get('[data-cy=email-input]').type('new@example.com')
|
||||
cy.get('[data-cy=password-input]').type('password123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('password123')
|
||||
|
||||
// 尝试提交
|
||||
cy.get('[data-cy=register-button]').should('be.disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logout', () => {
|
||||
it('should logout successfully', () => {
|
||||
// 先登录
|
||||
cy.login()
|
||||
|
||||
// 登出
|
||||
cy.logout()
|
||||
|
||||
// 验证登出成功
|
||||
cy.url().should('include', '/auth/login')
|
||||
cy.shouldNotHaveLocalStorage('auth_token')
|
||||
})
|
||||
|
||||
it('should clear user data on logout', () => {
|
||||
cy.login()
|
||||
|
||||
// 设置一些用户数据
|
||||
cy.setLocalStorage('user_preferences', '{"theme":"dark"}')
|
||||
|
||||
cy.logout()
|
||||
|
||||
// 验证数据被清除
|
||||
cy.shouldNotHaveLocalStorage('auth_token')
|
||||
cy.shouldNotHaveLocalStorage('user_info')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Reset', () => {
|
||||
it('should send reset email', () => {
|
||||
cy.visit('/auth/forgot-password')
|
||||
|
||||
cy.intercept('POST', '/api/auth/forgot-password', {
|
||||
statusCode: 200,
|
||||
body: { message: '重置邮件已发送' }
|
||||
}).as('forgotPassword')
|
||||
|
||||
cy.get('[data-cy=email-input]').type('test@example.com')
|
||||
cy.get('[data-cy=send-reset-button]').click()
|
||||
|
||||
cy.wait('@forgotPassword')
|
||||
cy.shouldShowSuccess('重置邮件已发送')
|
||||
})
|
||||
|
||||
it('should reset password with valid token', () => {
|
||||
cy.visit('/auth/reset-password?token=valid-token')
|
||||
|
||||
cy.intercept('POST', '/api/auth/reset-password', {
|
||||
statusCode: 200,
|
||||
body: { message: '密码重置成功' }
|
||||
}).as('resetPassword')
|
||||
|
||||
cy.get('[data-cy=new-password-input]').type('newpassword123')
|
||||
cy.get('[data-cy=confirm-password-input]').type('newpassword123')
|
||||
cy.get('[data-cy=reset-button]').click()
|
||||
|
||||
cy.wait('@resetPassword')
|
||||
cy.shouldShowSuccess('密码重置成功')
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should handle token expiration', () => {
|
||||
cy.login()
|
||||
|
||||
// 模拟token过期
|
||||
cy.intercept('GET', '/api/user/profile', {
|
||||
statusCode: 401,
|
||||
body: { message: 'Token expired' }
|
||||
}).as('tokenExpired')
|
||||
|
||||
// 访问需要认证的页面
|
||||
cy.visit('/app/dashboard')
|
||||
cy.wait('@tokenExpired')
|
||||
|
||||
// 应该重定向到登录页
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
|
||||
it('should refresh token automatically', () => {
|
||||
cy.login()
|
||||
|
||||
// 模拟token即将过期
|
||||
cy.intercept('POST', '/api/auth/refresh', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
token: 'new-token',
|
||||
expiresIn: 7200
|
||||
}
|
||||
}).as('refreshToken')
|
||||
|
||||
// 触发token刷新
|
||||
cy.visit('/app/dashboard')
|
||||
cy.wait('@refreshToken')
|
||||
|
||||
// 验证新token被保存
|
||||
cy.shouldHaveLocalStorage('auth_token', 'new-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should work on mobile devices', () => {
|
||||
cy.setMobileViewport()
|
||||
cy.visit('/auth/login')
|
||||
|
||||
cy.shouldBeVisible('[data-cy=login-form]')
|
||||
cy.get('[data-cy=username-input]').should('be.visible')
|
||||
cy.get('[data-cy=password-input]').should('be.visible')
|
||||
cy.get('[data-cy=login-button]').should('be.visible')
|
||||
})
|
||||
|
||||
it('should adapt to different screen sizes', () => {
|
||||
cy.visit('/auth/login')
|
||||
cy.checkResponsive('[data-cy=login-form]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should be accessible', () => {
|
||||
cy.visit('/auth/login')
|
||||
cy.checkA11y()
|
||||
})
|
||||
|
||||
it('should support keyboard navigation', () => {
|
||||
cy.visit('/auth/login')
|
||||
|
||||
cy.get('body').tab()
|
||||
cy.focused().should('have.attr', 'data-cy', 'username-input')
|
||||
|
||||
cy.focused().tab()
|
||||
cy.focused().should('have.attr', 'data-cy', 'password-input')
|
||||
|
||||
cy.focused().tab()
|
||||
cy.focused().should('have.attr', 'data-cy', 'login-button')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* E2E 测试自定义命令
|
||||
*/
|
||||
|
||||
// 登录命令
|
||||
Cypress.Commands.add('login', (username?: string, password?: string) => {
|
||||
const user = username || Cypress.env('testUser').username
|
||||
const pass = password || Cypress.env('testUser').password
|
||||
|
||||
cy.visit('/auth/login')
|
||||
cy.get('[data-cy=username-input]').type(user)
|
||||
cy.get('[data-cy=password-input]').type(pass)
|
||||
cy.get('[data-cy=login-button]').click()
|
||||
|
||||
// 等待登录完成
|
||||
cy.wait('@login')
|
||||
cy.url().should('not.include', '/auth/login')
|
||||
})
|
||||
|
||||
// 登出命令
|
||||
Cypress.Commands.add('logout', () => {
|
||||
cy.get('[data-cy=user-menu]').click()
|
||||
cy.get('[data-cy=logout-button]').click()
|
||||
cy.wait('@logout')
|
||||
cy.url().should('include', '/auth/login')
|
||||
})
|
||||
|
||||
// 等待页面加载完成
|
||||
Cypress.Commands.add('waitForPageLoad', () => {
|
||||
cy.get('[data-cy=loading]').should('not.exist')
|
||||
cy.get('body').should('be.visible')
|
||||
})
|
||||
|
||||
// 检查元素是否可见
|
||||
Cypress.Commands.add('shouldBeVisible', (selector: string) => {
|
||||
cy.get(selector).should('be.visible')
|
||||
})
|
||||
|
||||
// 检查元素是否包含文本
|
||||
Cypress.Commands.add('shouldContainText', (selector: string, text: string) => {
|
||||
cy.get(selector).should('contain.text', text)
|
||||
})
|
||||
|
||||
// 上传文件命令
|
||||
Cypress.Commands.add('uploadFile', (selector: string, fileName: string) => {
|
||||
cy.fixture(fileName, 'base64').then(fileContent => {
|
||||
cy.get(selector).selectFile({
|
||||
contents: Cypress.Buffer.from(fileContent, 'base64'),
|
||||
fileName,
|
||||
mimeType: 'image/jpeg'
|
||||
}, { force: true })
|
||||
})
|
||||
})
|
||||
|
||||
// 等待 API 请求完成
|
||||
Cypress.Commands.add('waitForApi', (alias: string) => {
|
||||
cy.wait(`@${alias}`)
|
||||
})
|
||||
|
||||
// 模拟网络延迟
|
||||
Cypress.Commands.add('simulateNetworkDelay', (delay: number) => {
|
||||
cy.intercept('**', (req) => {
|
||||
req.reply((res) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(res), delay)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 检查无障碍性
|
||||
Cypress.Commands.add('checkA11y', () => {
|
||||
cy.injectAxe()
|
||||
cy.checkA11y()
|
||||
})
|
||||
|
||||
// 自定义断言
|
||||
Cypress.Commands.add('shouldHaveClass', { prevSubject: true }, (subject, className) => {
|
||||
cy.wrap(subject).should('have.class', className)
|
||||
})
|
||||
|
||||
Cypress.Commands.add('shouldNotHaveClass', { prevSubject: true }, (subject, className) => {
|
||||
cy.wrap(subject).should('not.have.class', className)
|
||||
})
|
||||
|
||||
// 表单填写命令
|
||||
Cypress.Commands.add('fillForm', (formData: Record<string, string>) => {
|
||||
Object.entries(formData).forEach(([field, value]) => {
|
||||
cy.get(`[data-cy=${field}-input]`).clear().type(value)
|
||||
})
|
||||
})
|
||||
|
||||
// 等待元素出现
|
||||
Cypress.Commands.add('waitForElement', (selector: string, timeout = 10000) => {
|
||||
cy.get(selector, { timeout }).should('exist')
|
||||
})
|
||||
|
||||
// 滚动到元素
|
||||
Cypress.Commands.add('scrollToElement', (selector: string) => {
|
||||
cy.get(selector).scrollIntoView()
|
||||
})
|
||||
|
||||
// 模拟移动设备
|
||||
Cypress.Commands.add('setMobileViewport', () => {
|
||||
cy.viewport(375, 667) // iPhone 6/7/8 尺寸
|
||||
})
|
||||
|
||||
// 模拟平板设备
|
||||
Cypress.Commands.add('setTabletViewport', () => {
|
||||
cy.viewport(768, 1024) // iPad 尺寸
|
||||
})
|
||||
|
||||
// 模拟桌面设备
|
||||
Cypress.Commands.add('setDesktopViewport', () => {
|
||||
cy.viewport(1280, 720)
|
||||
})
|
||||
|
||||
// 检查响应式设计
|
||||
Cypress.Commands.add('checkResponsive', (selector: string) => {
|
||||
// 桌面
|
||||
cy.setDesktopViewport()
|
||||
cy.get(selector).should('be.visible')
|
||||
|
||||
// 平板
|
||||
cy.setTabletViewport()
|
||||
cy.get(selector).should('be.visible')
|
||||
|
||||
// 移动
|
||||
cy.setMobileViewport()
|
||||
cy.get(selector).should('be.visible')
|
||||
|
||||
// 恢复桌面
|
||||
cy.setDesktopViewport()
|
||||
})
|
||||
|
||||
// 模拟键盘导航
|
||||
Cypress.Commands.add('navigateWithKeyboard', (selector: string) => {
|
||||
cy.get('body').tab()
|
||||
cy.focused().should('have.attr', 'data-cy', selector)
|
||||
})
|
||||
|
||||
// 检查加载状态
|
||||
Cypress.Commands.add('shouldBeLoading', (selector: string) => {
|
||||
cy.get(selector).should('have.class', 'loading')
|
||||
})
|
||||
|
||||
Cypress.Commands.add('shouldNotBeLoading', (selector: string) => {
|
||||
cy.get(selector).should('not.have.class', 'loading')
|
||||
})
|
||||
|
||||
// 模拟网络错误
|
||||
Cypress.Commands.add('simulateNetworkError', (url: string) => {
|
||||
cy.intercept('GET', url, { forceNetworkError: true })
|
||||
})
|
||||
|
||||
// 检查错误消息
|
||||
Cypress.Commands.add('shouldShowError', (message: string) => {
|
||||
cy.get('[data-cy=error-message]').should('contain.text', message)
|
||||
})
|
||||
|
||||
// 检查成功消息
|
||||
Cypress.Commands.add('shouldShowSuccess', (message: string) => {
|
||||
cy.get('[data-cy=success-message]').should('contain.text', message)
|
||||
})
|
||||
|
||||
// 清除通知
|
||||
Cypress.Commands.add('clearNotifications', () => {
|
||||
cy.get('[data-cy=notification-close]').each(($el) => {
|
||||
cy.wrap($el).click()
|
||||
})
|
||||
})
|
||||
|
||||
// 等待动画完成
|
||||
Cypress.Commands.add('waitForAnimation', (selector?: string) => {
|
||||
if (selector) {
|
||||
cy.get(selector).should('not.have.class', 'animating')
|
||||
} else {
|
||||
cy.wait(300) // 默认等待动画时间
|
||||
}
|
||||
})
|
||||
|
||||
// 模拟拖拽
|
||||
Cypress.Commands.add('dragAndDrop', (sourceSelector: string, targetSelector: string) => {
|
||||
cy.get(sourceSelector).trigger('mousedown', { which: 1 })
|
||||
cy.get(targetSelector).trigger('mousemove').trigger('mouseup')
|
||||
})
|
||||
|
||||
// 检查本地存储
|
||||
Cypress.Commands.add('shouldHaveLocalStorage', (key: string, value?: string) => {
|
||||
cy.window().its('localStorage').invoke('getItem', key).should('exist')
|
||||
if (value) {
|
||||
cy.window().its('localStorage').invoke('getItem', key).should('eq', value)
|
||||
}
|
||||
})
|
||||
|
||||
// 清除本地存储特定项
|
||||
Cypress.Commands.add('clearLocalStorageItem', (key: string) => {
|
||||
cy.window().its('localStorage').invoke('removeItem', key)
|
||||
})
|
||||
|
||||
// 设置本地存储
|
||||
Cypress.Commands.add('setLocalStorage', (key: string, value: string) => {
|
||||
cy.window().its('localStorage').invoke('setItem', key, value)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* E2E 测试支持文件
|
||||
*/
|
||||
|
||||
// 导入 Cypress 命令
|
||||
import './commands'
|
||||
|
||||
// 全局配置
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
// 忽略某些预期的错误
|
||||
if (err.message.includes('ResizeObserver loop limit exceeded')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (err.message.includes('Non-Error promise rejection captured')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 返回 false 阻止 Cypress 失败测试
|
||||
return false
|
||||
})
|
||||
|
||||
// 测试前钩子
|
||||
beforeEach(() => {
|
||||
// 清除本地存储
|
||||
cy.clearLocalStorage()
|
||||
cy.clearCookies()
|
||||
|
||||
// 设置视口
|
||||
cy.viewport(1280, 720)
|
||||
|
||||
// 拦截 API 请求(可选)
|
||||
cy.intercept('GET', '/api/user/profile', { fixture: 'user.json' }).as('getUserProfile')
|
||||
cy.intercept('POST', '/api/auth/login', { fixture: 'auth.json' }).as('login')
|
||||
cy.intercept('POST', '/api/auth/logout', { statusCode: 200 }).as('logout')
|
||||
})
|
||||
|
||||
// 测试后钩子
|
||||
afterEach(() => {
|
||||
// 清理工作
|
||||
cy.clearLocalStorage()
|
||||
|
||||
// 截图(失败时)
|
||||
cy.screenshot({ capture: 'runner', onlyOnFailure: true })
|
||||
})
|
||||
|
||||
// 自定义断言
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/**
|
||||
* 登录用户
|
||||
*/
|
||||
login(username?: string, password?: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 登出用户
|
||||
*/
|
||||
logout(): Chainable<void>
|
||||
|
||||
/**
|
||||
* 等待页面加载完成
|
||||
*/
|
||||
waitForPageLoad(): Chainable<void>
|
||||
|
||||
/**
|
||||
* 检查元素是否可见
|
||||
*/
|
||||
shouldBeVisible(selector: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 检查元素是否包含文本
|
||||
*/
|
||||
shouldContainText(selector: string, text: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
uploadFile(selector: string, fileName: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 等待 API 请求完成
|
||||
*/
|
||||
waitForApi(alias: string): Chainable<void>
|
||||
|
||||
/**
|
||||
* 模拟网络延迟
|
||||
*/
|
||||
simulateNetworkDelay(delay: number): Chainable<void>
|
||||
|
||||
/**
|
||||
* 检查无障碍性
|
||||
*/
|
||||
checkA11y(): Chainable<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 测试环境设置
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest'
|
||||
import { config } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
|
||||
// 全局组件注册
|
||||
config.global.plugins = [ElementPlus]
|
||||
|
||||
// 全局属性
|
||||
config.global.config.globalProperties = {
|
||||
$t: (key: string) => key, // 模拟国际化
|
||||
$route: {
|
||||
path: '/',
|
||||
params: {},
|
||||
query: {},
|
||||
meta: {}
|
||||
},
|
||||
$router: {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟全局对象
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// 模拟 ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// 模拟 IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// 模拟 localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
global.localStorage = localStorageMock
|
||||
|
||||
// 模拟 sessionStorage
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
global.sessionStorage = sessionStorageMock
|
||||
|
||||
// 模拟 fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// 模拟 URL.createObjectURL
|
||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// 模拟 Notification
|
||||
global.Notification = vi.fn().mockImplementation(() => ({
|
||||
close: vi.fn(),
|
||||
}))
|
||||
|
||||
// 模拟 navigator
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
readText: vi.fn().mockResolvedValue(''),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// 模拟 document.execCommand
|
||||
document.execCommand = vi.fn()
|
||||
|
||||
// 模拟 getSelection
|
||||
global.getSelection = vi.fn().mockReturnValue({
|
||||
toString: vi.fn().mockReturnValue(''),
|
||||
removeAllRanges: vi.fn(),
|
||||
addRange: vi.fn(),
|
||||
})
|
||||
|
||||
// 模拟 console 方法(避免测试时输出过多日志)
|
||||
global.console = {
|
||||
...console,
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
|
||||
// 设置测试环境变量
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.VITE_APP_ENV = 'test'
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 文件上传组件测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ElUpload, ElButton } from 'element-plus'
|
||||
import FileUpload from '@/components/upload/FileUpload.vue'
|
||||
|
||||
// 模拟 Element Plus 组件
|
||||
vi.mock('element-plus', () => ({
|
||||
ElUpload: {
|
||||
name: 'ElUpload',
|
||||
template: '<div class="el-upload"><slot /></div>',
|
||||
props: ['action', 'headers', 'data', 'multiple', 'accept', 'limit', 'fileList', 'beforeUpload', 'onProgress', 'onSuccess', 'onError', 'onRemove', 'onExceed', 'autoUpload', 'showFileList', 'drag', 'disabled']
|
||||
},
|
||||
ElButton: {
|
||||
name: 'ElButton',
|
||||
template: '<button class="el-button"><slot /></button>',
|
||||
props: ['type', 'disabled']
|
||||
},
|
||||
ElIcon: {
|
||||
name: 'ElIcon',
|
||||
template: '<i class="el-icon"><slot /></i>'
|
||||
},
|
||||
ElProgress: {
|
||||
name: 'ElProgress',
|
||||
template: '<div class="el-progress"></div>',
|
||||
props: ['percentage', 'status', 'strokeWidth']
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟图标组件
|
||||
vi.mock('@element-plus/icons-vue', () => ({
|
||||
UploadFilled: { name: 'UploadFilled' },
|
||||
Upload: { name: 'Upload' },
|
||||
Document: { name: 'Document' },
|
||||
Picture: { name: 'Picture' }
|
||||
}))
|
||||
|
||||
// 模拟认证状态
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
token: 'mock-token'
|
||||
})
|
||||
}))
|
||||
|
||||
// 模拟配置
|
||||
vi.mock('@/config/constants', () => ({
|
||||
UPLOAD_CONFIG: {
|
||||
DEFAULT_UPLOAD_URL: '/api/upload',
|
||||
IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif'],
|
||||
DOCUMENT_TYPES: ['application/pdf', 'application/msword'],
|
||||
VIDEO_TYPES: ['video/mp4', 'video/avi'],
|
||||
AUDIO_TYPES: ['audio/mp3', 'audio/wav']
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟格式化工具
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${size} B`
|
||||
}))
|
||||
|
||||
describe('FileUpload', () => {
|
||||
let wrapper: any
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mount(FileUpload, {
|
||||
props: {
|
||||
action: '/api/upload',
|
||||
multiple: false,
|
||||
accept: 'image/*',
|
||||
limit: 5,
|
||||
maxSize: 1024 * 1024, // 1MB
|
||||
autoUpload: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
it('should render correctly', () => {
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('.file-upload').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render upload button when not drag mode', () => {
|
||||
expect(wrapper.find('.el-button').exists()).toBe(true)
|
||||
expect(wrapper.find('.upload-dragger').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should render drag area when drag mode is enabled', async () => {
|
||||
await wrapper.setProps({ drag: true })
|
||||
expect(wrapper.find('.upload-dragger').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show upload hint', () => {
|
||||
expect(wrapper.find('.upload-tip').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should emit events correctly', async () => {
|
||||
const file = new File(['test'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
// 模拟文件上传成功
|
||||
await wrapper.vm.handleSuccess({ url: 'http://example.com/file.txt' }, { uid: '1', name: 'test.txt' })
|
||||
|
||||
expect(wrapper.emitted('success')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should validate file type', () => {
|
||||
const validFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
|
||||
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
// 设置接受的文件类型
|
||||
wrapper.vm.acceptTypes = 'image/jpeg,image/png'
|
||||
|
||||
expect(wrapper.vm.isValidFileType(validFile)).toBe(true)
|
||||
expect(wrapper.vm.isValidFileType(invalidFile)).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate file size', async () => {
|
||||
const smallFile = new File(['small'], 'small.txt', { type: 'text/plain' })
|
||||
Object.defineProperty(smallFile, 'size', { value: 500 })
|
||||
|
||||
const largeFile = new File(['large'], 'large.txt', { type: 'text/plain' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 2 * 1024 * 1024 }) // 2MB
|
||||
|
||||
// 测试文件大小验证
|
||||
const result1 = await wrapper.vm.handleBeforeUpload(smallFile)
|
||||
expect(result1).toBe(true)
|
||||
|
||||
const result2 = await wrapper.vm.handleBeforeUpload(largeFile)
|
||||
expect(result2).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle upload progress', () => {
|
||||
const progressEvent = { percent: 50 }
|
||||
const file = { uid: '1', name: 'test.txt' }
|
||||
|
||||
wrapper.vm.handleProgress(progressEvent, file)
|
||||
|
||||
expect(wrapper.vm.uploadPercent).toBe(50)
|
||||
expect(wrapper.emitted('progress')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle upload error', () => {
|
||||
const error = new Error('Upload failed')
|
||||
const file = { uid: '1', name: 'test.txt' }
|
||||
|
||||
wrapper.vm.handleError(error, file)
|
||||
|
||||
expect(wrapper.vm.uploadStatus).toBe('exception')
|
||||
expect(wrapper.emitted('error')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle file removal', () => {
|
||||
const file = { uid: '1', name: 'test.txt' }
|
||||
|
||||
wrapper.vm.handleRemove(file)
|
||||
|
||||
expect(wrapper.emitted('remove')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle exceed limit', () => {
|
||||
wrapper.vm.handleExceed()
|
||||
|
||||
// 应该显示警告消息(这里我们只能检查方法是否被调用)
|
||||
expect(true).toBe(true) // 占位断言
|
||||
})
|
||||
|
||||
it('should clear files', () => {
|
||||
wrapper.vm.fileList = [
|
||||
{ uid: '1', name: 'test1.txt' },
|
||||
{ uid: '2', name: 'test2.txt' }
|
||||
]
|
||||
|
||||
wrapper.vm.clearFiles()
|
||||
|
||||
expect(wrapper.vm.fileList).toEqual([])
|
||||
})
|
||||
|
||||
it('should compute upload headers correctly', () => {
|
||||
const headers = wrapper.vm.uploadHeaders
|
||||
|
||||
expect(headers).toHaveProperty('Authorization')
|
||||
expect(headers.Authorization).toBe('Bearer mock-token')
|
||||
expect(headers['X-Requested-With']).toBe('XMLHttpRequest')
|
||||
})
|
||||
|
||||
it('should compute upload data correctly', async () => {
|
||||
await wrapper.setProps({
|
||||
fileType: 'image',
|
||||
data: { category: 'avatar' }
|
||||
})
|
||||
|
||||
const data = wrapper.vm.uploadData
|
||||
|
||||
expect(data.type).toBe('image')
|
||||
expect(data.category).toBe('avatar')
|
||||
})
|
||||
|
||||
it('should compute accept types correctly', async () => {
|
||||
await wrapper.setProps({ fileType: 'image' })
|
||||
expect(wrapper.vm.acceptTypes).toBe('image/jpeg,image/png,image/gif')
|
||||
|
||||
await wrapper.setProps({ fileType: 'document' })
|
||||
expect(wrapper.vm.acceptTypes).toBe('application/pdf,application/msword')
|
||||
|
||||
await wrapper.setProps({ accept: 'custom/*' })
|
||||
expect(wrapper.vm.acceptTypes).toBe('custom/*')
|
||||
})
|
||||
|
||||
it('should compute upload hint correctly', async () => {
|
||||
await wrapper.setProps({
|
||||
fileType: 'image',
|
||||
limit: 3,
|
||||
maxSize: 1024 * 1024
|
||||
})
|
||||
|
||||
const hint = wrapper.vm.uploadHint
|
||||
|
||||
expect(hint).toContain('最多3个文件')
|
||||
expect(hint).toContain('JPG、PNG、GIF')
|
||||
expect(hint).toContain('1024 B') // 模拟的格式化结果
|
||||
})
|
||||
|
||||
it('should handle disabled state', async () => {
|
||||
await wrapper.setProps({ disabled: true })
|
||||
|
||||
expect(wrapper.find('.el-button').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle custom button text and type', async () => {
|
||||
await wrapper.setProps({
|
||||
buttonText: 'Custom Upload',
|
||||
buttonType: 'success'
|
||||
})
|
||||
|
||||
const button = wrapper.find('.el-button')
|
||||
expect(button.text()).toContain('Custom Upload')
|
||||
expect(button.attributes('type')).toBe('success')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 认证状态管理测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 模拟 API
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
getUserInfo: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟存储工具
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// 模拟路由
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => mockRouter
|
||||
}))
|
||||
|
||||
// 模拟配置
|
||||
vi.mock('@/config/constants', () => ({
|
||||
STORAGE_KEYS: {
|
||||
TOKEN: 'auth_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
USER_INFO: 'user_info'
|
||||
},
|
||||
TOKEN_CONFIG: {
|
||||
EXPIRES_IN: 7200,
|
||||
REFRESH_THRESHOLD: 300
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
let authStore: ReturnType<typeof useAuthStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
authStore = useAuthStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have correct initial state', () => {
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.refreshToken).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(authStore.isTokenExpired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getters', () => {
|
||||
it('should compute isAuthenticated correctly', () => {
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
|
||||
authStore.token = 'valid-token'
|
||||
authStore.tokenExpireTime = Date.now() + 3600000 // 1小时后过期
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('should compute isTokenExpired correctly', () => {
|
||||
expect(authStore.isTokenExpired).toBe(true)
|
||||
|
||||
authStore.tokenExpireTime = Date.now() + 3600000 // 1小时后过期
|
||||
expect(authStore.isTokenExpired).toBe(false)
|
||||
|
||||
authStore.tokenExpireTime = Date.now() - 3600000 // 1小时前过期
|
||||
expect(authStore.isTokenExpired).toBe(true)
|
||||
})
|
||||
|
||||
it('should compute needsRefresh correctly', () => {
|
||||
authStore.tokenExpireTime = Date.now() + 600000 // 10分钟后过期
|
||||
expect(authStore.needsRefresh).toBe(true)
|
||||
|
||||
authStore.tokenExpireTime = Date.now() + 3600000 // 1小时后过期
|
||||
expect(authStore.needsRefresh).toBe(false)
|
||||
})
|
||||
|
||||
it('should compute user properties correctly', () => {
|
||||
authStore.user = {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
nickname: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatar: 'avatar.jpg',
|
||||
role: 'user',
|
||||
createTime: Date.now(),
|
||||
updateTime: Date.now()
|
||||
}
|
||||
|
||||
expect(authStore.userId).toBe('1')
|
||||
expect(authStore.username).toBe('testuser')
|
||||
expect(authStore.nickname).toBe('Test User')
|
||||
expect(authStore.email).toBe('test@example.com')
|
||||
expect(authStore.avatar).toBe('avatar.jpg')
|
||||
expect(authStore.userRole).toBe('user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions', () => {
|
||||
describe('login', () => {
|
||||
it('should login successfully', async () => {
|
||||
const mockResponse = {
|
||||
token: 'new-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 7200,
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
nickname: 'Test User',
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.login).mockResolvedValue(mockResponse)
|
||||
|
||||
const loginData = {
|
||||
username: 'testuser',
|
||||
password: 'password123'
|
||||
}
|
||||
|
||||
const result = await authStore.login(loginData)
|
||||
|
||||
expect(authApi.login).toHaveBeenCalledWith(loginData)
|
||||
expect(authStore.token).toBe('new-token')
|
||||
expect(authStore.refreshToken).toBe('new-refresh-token')
|
||||
expect(authStore.user).toEqual(mockResponse.user)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
const { authApi } = await import('@/api/auth')
|
||||
const error = new Error('Invalid credentials')
|
||||
vi.mocked(authApi.login).mockRejectedValue(error)
|
||||
|
||||
const loginData = {
|
||||
username: 'testuser',
|
||||
password: 'wrongpassword'
|
||||
}
|
||||
|
||||
await expect(authStore.login(loginData)).rejects.toThrow('Invalid credentials')
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('register', () => {
|
||||
it('should register successfully', async () => {
|
||||
const mockResponse = {
|
||||
token: 'new-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 7200,
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'newuser',
|
||||
nickname: 'New User',
|
||||
email: 'new@example.com'
|
||||
}
|
||||
}
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.register).mockResolvedValue(mockResponse)
|
||||
|
||||
const registerData = {
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
email: 'new@example.com',
|
||||
nickname: 'New User'
|
||||
}
|
||||
|
||||
const result = await authStore.register(registerData)
|
||||
|
||||
expect(authApi.register).toHaveBeenCalledWith(registerData)
|
||||
expect(authStore.token).toBe('new-token')
|
||||
expect(authStore.user).toEqual(mockResponse.user)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('should logout successfully', async () => {
|
||||
// 设置初始状态
|
||||
authStore.token = 'current-token'
|
||||
authStore.refreshToken = 'current-refresh-token'
|
||||
authStore.user = { id: '1', username: 'testuser' } as any
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.logout).mockResolvedValue(undefined)
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(authApi.logout).toHaveBeenCalled()
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.refreshToken).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
expect(authStore.tokenExpireTime).toBe(0)
|
||||
})
|
||||
|
||||
it('should clear state even if API call fails', async () => {
|
||||
authStore.token = 'current-token'
|
||||
authStore.user = { id: '1', username: 'testuser' } as any
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.logout).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.user).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('should refresh token successfully', async () => {
|
||||
authStore.refreshToken = 'current-refresh-token'
|
||||
|
||||
const mockResponse = {
|
||||
token: 'new-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 7200
|
||||
}
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.refreshToken).mockResolvedValue(mockResponse)
|
||||
|
||||
const result = await authStore.refreshTokenAction()
|
||||
|
||||
expect(authApi.refreshToken).toHaveBeenCalledWith('current-refresh-token')
|
||||
expect(authStore.token).toBe('new-token')
|
||||
expect(authStore.refreshToken).toBe('new-refresh-token')
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should handle refresh token failure', async () => {
|
||||
authStore.refreshToken = 'invalid-refresh-token'
|
||||
|
||||
const { authApi } = await import('@/api/auth')
|
||||
vi.mocked(authApi.refreshToken).mockRejectedValue(new Error('Invalid refresh token'))
|
||||
|
||||
await expect(authStore.refreshTokenAction()).rejects.toThrow('Invalid refresh token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserInfo', () => {
|
||||
it('should update user info', async () => {
|
||||
authStore.user = {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
nickname: 'Old Name',
|
||||
email: 'old@example.com'
|
||||
} as any
|
||||
|
||||
const updates = {
|
||||
nickname: 'New Name',
|
||||
email: 'new@example.com'
|
||||
}
|
||||
|
||||
await authStore.updateUserInfo(updates)
|
||||
|
||||
expect(authStore.user?.nickname).toBe('New Name')
|
||||
expect(authStore.user?.email).toBe('new@example.com')
|
||||
expect(authStore.user?.username).toBe('testuser') // 保持不变
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkAuthStatus', () => {
|
||||
it('should return true for valid authentication', () => {
|
||||
authStore.token = 'valid-token'
|
||||
authStore.tokenExpireTime = Date.now() + 3600000
|
||||
|
||||
expect(authStore.checkAuthStatus()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for expired token', () => {
|
||||
authStore.token = 'expired-token'
|
||||
authStore.tokenExpireTime = Date.now() - 3600000
|
||||
|
||||
expect(authStore.checkAuthStatus()).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for missing token', () => {
|
||||
authStore.token = ''
|
||||
|
||||
expect(authStore.checkAuthStatus()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistence', () => {
|
||||
it('should save state to storage', () => {
|
||||
const storage = require('@/utils/storage').default
|
||||
|
||||
authStore.token = 'test-token'
|
||||
authStore.refreshToken = 'test-refresh-token'
|
||||
authStore.user = { id: '1', username: 'testuser' } as any
|
||||
|
||||
authStore.saveToStorage()
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith('auth_token', 'test-token')
|
||||
expect(storage.set).toHaveBeenCalledWith('refresh_token', 'test-refresh-token')
|
||||
expect(storage.set).toHaveBeenCalledWith('user_info', authStore.user)
|
||||
})
|
||||
|
||||
it('should load state from storage', () => {
|
||||
const storage = require('@/utils/storage').default
|
||||
|
||||
storage.get.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'auth_token':
|
||||
return 'stored-token'
|
||||
case 'refresh_token':
|
||||
return 'stored-refresh-token'
|
||||
case 'user_info':
|
||||
return { id: '1', username: 'storeduser' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
authStore.loadFromStorage()
|
||||
|
||||
expect(authStore.token).toBe('stored-token')
|
||||
expect(authStore.refreshToken).toBe('stored-refresh-token')
|
||||
expect(authStore.user).toEqual({ id: '1', username: 'storeduser' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 格式化工具函数测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
formatRelativeTime,
|
||||
formatFileSize,
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
maskPhone,
|
||||
maskEmail,
|
||||
truncateText
|
||||
} from '@/utils/format'
|
||||
|
||||
describe('format utils', () => {
|
||||
beforeEach(() => {
|
||||
// 重置时间相关的模拟
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-15 12:00:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should format timestamp to date string', () => {
|
||||
const timestamp = new Date('2024-01-15').getTime()
|
||||
expect(formatDate(timestamp)).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('should format Date object to date string', () => {
|
||||
const date = new Date('2024-01-15')
|
||||
expect(formatDate(date)).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('should use custom format', () => {
|
||||
const timestamp = new Date('2024-01-15').getTime()
|
||||
expect(formatDate(timestamp, 'MM/DD/YYYY')).toBe('01/15/2024')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('should format timestamp to datetime string', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatDateTime(timestamp)).toBe('2024-01-15 12:30:45')
|
||||
})
|
||||
|
||||
it('should use custom format', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatDateTime(timestamp, 'YYYY年MM月DD日 HH:mm')).toBe('2024年01月15日 12:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('should format timestamp to time string', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatTime(timestamp)).toBe('12:30:45')
|
||||
})
|
||||
|
||||
it('should use custom format', () => {
|
||||
const timestamp = new Date('2024-01-15 12:30:45').getTime()
|
||||
expect(formatTime(timestamp, 'HH:mm')).toBe('12:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('should return "刚刚" for very recent time', () => {
|
||||
const timestamp = Date.now() - 1000 // 1秒前
|
||||
expect(formatRelativeTime(timestamp)).toBe('刚刚')
|
||||
})
|
||||
|
||||
it('should return minutes ago', () => {
|
||||
const timestamp = Date.now() - 5 * 60 * 1000 // 5分钟前
|
||||
expect(formatRelativeTime(timestamp)).toBe('5分钟前')
|
||||
})
|
||||
|
||||
it('should return hours ago', () => {
|
||||
const timestamp = Date.now() - 2 * 60 * 60 * 1000 // 2小时前
|
||||
expect(formatRelativeTime(timestamp)).toBe('2小时前')
|
||||
})
|
||||
|
||||
it('should return days ago', () => {
|
||||
const timestamp = Date.now() - 3 * 24 * 60 * 60 * 1000 // 3天前
|
||||
expect(formatRelativeTime(timestamp)).toBe('3天前')
|
||||
})
|
||||
|
||||
it('should return formatted date for old time', () => {
|
||||
const timestamp = Date.now() - 10 * 24 * 60 * 60 * 1000 // 10天前
|
||||
expect(formatRelativeTime(timestamp)).toMatch(/\d{4}-\d{2}-\d{2}/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('should format bytes', () => {
|
||||
expect(formatFileSize(512)).toBe('512 B')
|
||||
})
|
||||
|
||||
it('should format KB', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB')
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('should format MB', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB')
|
||||
expect(formatFileSize(1024 * 1024 * 2.5)).toBe('2.5 MB')
|
||||
})
|
||||
|
||||
it('should format GB', () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('should handle zero size', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('should handle negative size', () => {
|
||||
expect(formatFileSize(-1024)).toBe('0 B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('should format number with default options', () => {
|
||||
expect(formatNumber(1234.567)).toBe('1,234.567')
|
||||
})
|
||||
|
||||
it('should format number with custom decimal places', () => {
|
||||
expect(formatNumber(1234.567, { decimals: 2 })).toBe('1,234.57')
|
||||
})
|
||||
|
||||
it('should format number without separator', () => {
|
||||
expect(formatNumber(1234.567, { separator: false })).toBe('1234.567')
|
||||
})
|
||||
|
||||
it('should handle zero', () => {
|
||||
expect(formatNumber(0)).toBe('0')
|
||||
})
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
expect(formatNumber(-1234.567)).toBe('-1,234.567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
it('should format currency with default options', () => {
|
||||
expect(formatCurrency(1234.56)).toBe('¥1,234.56')
|
||||
})
|
||||
|
||||
it('should format currency with custom symbol', () => {
|
||||
expect(formatCurrency(1234.56, { symbol: '$' })).toBe('$1,234.56')
|
||||
})
|
||||
|
||||
it('should format currency with custom decimal places', () => {
|
||||
expect(formatCurrency(1234.567, { decimals: 3 })).toBe('¥1,234.567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('maskPhone', () => {
|
||||
it('should mask phone number', () => {
|
||||
expect(maskPhone('13812345678')).toBe('138****5678')
|
||||
})
|
||||
|
||||
it('should handle short phone number', () => {
|
||||
expect(maskPhone('12345')).toBe('12345')
|
||||
})
|
||||
|
||||
it('should handle empty phone number', () => {
|
||||
expect(maskPhone('')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
expect(maskPhone(null)).toBe('')
|
||||
expect(maskPhone(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('should mask email address', () => {
|
||||
expect(maskEmail('test@example.com')).toBe('t***@example.com')
|
||||
})
|
||||
|
||||
it('should handle short email', () => {
|
||||
expect(maskEmail('a@b.c')).toBe('a***@b.c')
|
||||
})
|
||||
|
||||
it('should handle invalid email', () => {
|
||||
expect(maskEmail('invalid-email')).toBe('invalid-email')
|
||||
})
|
||||
|
||||
it('should handle empty email', () => {
|
||||
expect(maskEmail('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('should truncate long text', () => {
|
||||
const text = 'This is a very long text that should be truncated'
|
||||
expect(truncateText(text, 20)).toBe('This is a very long...')
|
||||
})
|
||||
|
||||
it('should not truncate short text', () => {
|
||||
const text = 'Short text'
|
||||
expect(truncateText(text, 20)).toBe('Short text')
|
||||
})
|
||||
|
||||
it('should use custom suffix', () => {
|
||||
const text = 'This is a very long text'
|
||||
expect(truncateText(text, 10, '---')).toBe('This is a---')
|
||||
})
|
||||
|
||||
it('should handle empty text', () => {
|
||||
expect(truncateText('', 10)).toBe('')
|
||||
})
|
||||
|
||||
it('should handle zero length', () => {
|
||||
expect(truncateText('Hello', 0)).toBe('...')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 验证工具函数测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
validateEmail,
|
||||
validatePhone,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
validateUrl,
|
||||
validateIdCard,
|
||||
validateRequired,
|
||||
validateLength,
|
||||
validateNumber,
|
||||
validateInteger,
|
||||
validatePositive,
|
||||
validateRange
|
||||
} from '@/utils/validation'
|
||||
|
||||
describe('validation utils', () => {
|
||||
describe('validateEmail', () => {
|
||||
it('should validate correct email addresses', () => {
|
||||
expect(validateEmail('test@example.com')).toBe(true)
|
||||
expect(validateEmail('user.name@domain.co.uk')).toBe(true)
|
||||
expect(validateEmail('user+tag@example.org')).toBe(true)
|
||||
expect(validateEmail('123@456.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email addresses', () => {
|
||||
expect(validateEmail('invalid-email')).toBe(false)
|
||||
expect(validateEmail('test@')).toBe(false)
|
||||
expect(validateEmail('@example.com')).toBe(false)
|
||||
expect(validateEmail('test..test@example.com')).toBe(false)
|
||||
expect(validateEmail('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePhone', () => {
|
||||
it('should validate correct phone numbers', () => {
|
||||
expect(validatePhone('13812345678')).toBe(true)
|
||||
expect(validatePhone('15987654321')).toBe(true)
|
||||
expect(validatePhone('18612345678')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid phone numbers', () => {
|
||||
expect(validatePhone('12345678901')).toBe(false) // 不是1开头
|
||||
expect(validatePhone('1381234567')).toBe(false) // 长度不够
|
||||
expect(validatePhone('138123456789')).toBe(false) // 长度过长
|
||||
expect(validatePhone('13a12345678')).toBe(false) // 包含字母
|
||||
expect(validatePhone('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('should validate correct passwords', () => {
|
||||
expect(validatePassword('abc123')).toBe(true)
|
||||
expect(validatePassword('Password1')).toBe(true)
|
||||
expect(validatePassword('test123456')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid passwords', () => {
|
||||
expect(validatePassword('12345')).toBe(false) // 长度不够
|
||||
expect(validatePassword('abcdef')).toBe(false) // 只有字母
|
||||
expect(validatePassword('123456')).toBe(false) // 只有数字
|
||||
expect(validatePassword('')).toBe(false)
|
||||
expect(validatePassword('a'.repeat(21))).toBe(false) // 长度过长
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUsername', () => {
|
||||
it('should validate correct usernames', () => {
|
||||
expect(validateUsername('user123')).toBe(true)
|
||||
expect(validateUsername('test_user')).toBe(true)
|
||||
expect(validateUsername('用户名')).toBe(true)
|
||||
expect(validateUsername('user_123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid usernames', () => {
|
||||
expect(validateUsername('ab')).toBe(false) // 长度不够
|
||||
expect(validateUsername('user-name')).toBe(false) // 包含连字符
|
||||
expect(validateUsername('user@name')).toBe(false) // 包含特殊字符
|
||||
expect(validateUsername('')).toBe(false)
|
||||
expect(validateUsername('a'.repeat(21))).toBe(false) // 长度过长
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUrl', () => {
|
||||
it('should validate correct URLs', () => {
|
||||
expect(validateUrl('https://example.com')).toBe(true)
|
||||
expect(validateUrl('http://test.org')).toBe(true)
|
||||
expect(validateUrl('https://sub.domain.com/path?query=1')).toBe(true)
|
||||
expect(validateUrl('ftp://files.example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid URLs', () => {
|
||||
expect(validateUrl('not-a-url')).toBe(false)
|
||||
expect(validateUrl('example.com')).toBe(false) // 缺少协议
|
||||
expect(validateUrl('http://')).toBe(false)
|
||||
expect(validateUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateIdCard', () => {
|
||||
it('should validate correct ID card numbers', () => {
|
||||
expect(validateIdCard('110101199003077777')).toBe(true)
|
||||
expect(validateIdCard('11010119900307777X')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid ID card numbers', () => {
|
||||
expect(validateIdCard('12345678901234567')).toBe(false) // 长度不够
|
||||
expect(validateIdCard('1234567890123456789')).toBe(false) // 长度过长
|
||||
expect(validateIdCard('11010119900307777Y')).toBe(false) // 最后一位不是X
|
||||
expect(validateIdCard('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateRequired', () => {
|
||||
it('should validate required values', () => {
|
||||
expect(validateRequired('test')).toBe(true)
|
||||
expect(validateRequired(123)).toBe(true)
|
||||
expect(validateRequired(0)).toBe(true)
|
||||
expect(validateRequired(false)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject empty values', () => {
|
||||
expect(validateRequired('')).toBe(false)
|
||||
expect(validateRequired(' ')).toBe(false) // 只有空格
|
||||
expect(validateRequired(null)).toBe(false)
|
||||
expect(validateRequired(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateLength', () => {
|
||||
it('should validate correct length', () => {
|
||||
expect(validateLength('test', 3, 5)).toBe(true)
|
||||
expect(validateLength('hello', 5, 10)).toBe(true)
|
||||
expect(validateLength('ab', 1, 3)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject incorrect length', () => {
|
||||
expect(validateLength('ab', 3, 5)).toBe(false) // 太短
|
||||
expect(validateLength('toolong', 3, 5)).toBe(false) // 太长
|
||||
expect(validateLength('', 1, 5)).toBe(false) // 空字符串
|
||||
})
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(validateLength('test', 4, 4)).toBe(true) // 正好等于边界
|
||||
expect(validateLength('test', 0, 10)).toBe(true) // 最小长度为0
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateNumber', () => {
|
||||
it('should validate numbers', () => {
|
||||
expect(validateNumber(123)).toBe(true)
|
||||
expect(validateNumber(0)).toBe(true)
|
||||
expect(validateNumber(-456)).toBe(true)
|
||||
expect(validateNumber(3.14)).toBe(true)
|
||||
expect(validateNumber('123')).toBe(true)
|
||||
expect(validateNumber('3.14')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-numbers', () => {
|
||||
expect(validateNumber('abc')).toBe(false)
|
||||
expect(validateNumber('12abc')).toBe(false)
|
||||
expect(validateNumber('')).toBe(false)
|
||||
expect(validateNumber(null)).toBe(false)
|
||||
expect(validateNumber(undefined)).toBe(false)
|
||||
expect(validateNumber(NaN)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateInteger', () => {
|
||||
it('should validate integers', () => {
|
||||
expect(validateInteger(123)).toBe(true)
|
||||
expect(validateInteger(0)).toBe(true)
|
||||
expect(validateInteger(-456)).toBe(true)
|
||||
expect(validateInteger('123')).toBe(true)
|
||||
expect(validateInteger('-456')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-integers', () => {
|
||||
expect(validateInteger(3.14)).toBe(false)
|
||||
expect(validateInteger('3.14')).toBe(false)
|
||||
expect(validateInteger('abc')).toBe(false)
|
||||
expect(validateInteger('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePositive', () => {
|
||||
it('should validate positive numbers', () => {
|
||||
expect(validatePositive(123)).toBe(true)
|
||||
expect(validatePositive(0.1)).toBe(true)
|
||||
expect(validatePositive('123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-positive numbers', () => {
|
||||
expect(validatePositive(0)).toBe(false)
|
||||
expect(validatePositive(-123)).toBe(false)
|
||||
expect(validatePositive('-123')).toBe(false)
|
||||
expect(validatePositive('abc')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateRange', () => {
|
||||
it('should validate numbers in range', () => {
|
||||
expect(validateRange(5, 1, 10)).toBe(true)
|
||||
expect(validateRange(1, 1, 10)).toBe(true) // 边界值
|
||||
expect(validateRange(10, 1, 10)).toBe(true) // 边界值
|
||||
expect(validateRange('5', 1, 10)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject numbers out of range', () => {
|
||||
expect(validateRange(0, 1, 10)).toBe(false)
|
||||
expect(validateRange(11, 1, 10)).toBe(false)
|
||||
expect(validateRange(-5, 1, 10)).toBe(false)
|
||||
expect(validateRange('abc', 1, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user