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>
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user