修复多个功能

czq
2991692032 3 weeks ago
parent 84099dfc58
commit 0e176c9883

@ -0,0 +1,235 @@
# UniLife 单元测试设计文档
## 📋 概述
本文档详细描述了 UniLife 项目四个核心模块的单元测试设计方案:论坛模块、资源模块、课程表模块和用户模块。
## 🏗️ 测试架构
### 后端测试框架
- **JUnit 5**: 主要测试框架
- **Mockito**: Mock框架用于模拟依赖
- **Spring Boot Test**: Spring Boot测试支持
- **H2 Database**: 内存数据库,用于测试
- **TestContainers**: 集成测试容器支持
### 前端测试框架
- **Vitest**: 现代化的Vue测试框架
- **Vue Test Utils**: Vue组件测试工具
- **jsdom**: DOM环境模拟
## 🎯 1. 论坛模块测试
### 1.1 后端测试
#### PostServiceTest
测试覆盖范围:
- ✅ **创建帖子成功** - 验证正常创建流程
- ❌ **用户不存在** - 验证用户验证逻辑
- ❌ **分类不存在** - 验证分类验证逻辑
- ❌ **标题为空** - 验证输入校验
- ✅ **获取帖子详情** - 验证详情获取和浏览量更新
- ❌ **帖子不存在** - 验证错误处理
- ✅ **获取帖子列表** - 验证分页和搜索
- ✅ **更新帖子** - 验证更新逻辑
- ❌ **无权限修改** - 验证权限控制
- ✅ **删除帖子** - 验证删除逻辑
- ✅ **点赞/取消点赞** - 验证点赞逻辑
#### PostControllerTest
测试覆盖范围:
- HTTP接口测试
- 权限验证
- 参数校验
- 响应格式验证
### 1.2 前端测试
#### forum.test.ts
测试覆盖范围:
- API调用正确性
- 参数传递验证
- 错误处理
- 响应数据解析
## 🗂️ 2. 资源模块测试
### 2.1 后端测试
#### ResourceServiceTest
测试覆盖范围:
- ✅ **文件上传成功** - 验证文件上传流程
- ❌ **用户不存在** - 验证用户验证
- ❌ **分类不存在** - 验证分类验证
- ❌ **空文件上传** - 验证文件校验
- ❌ **不支持的文件类型** - 验证文件类型限制
- ✅ **获取资源详情** - 验证详情获取
- ❌ **资源不存在** - 验证错误处理
- ✅ **获取资源列表** - 验证搜索和分页
- ✅ **更新资源信息** - 验证更新逻辑
- ❌ **无权限修改** - 验证权限控制
- ✅ **删除资源** - 验证删除逻辑
- ✅ **下载资源** - 验证下载逻辑和计数更新
- ✅ **点赞/取消点赞** - 验证点赞逻辑
### 2.2 前端测试
#### resources.test.ts
测试覆盖范围:
- 文件上传API测试
- 文件类型验证
- 下载权限测试
- 资源管理功能测试
## 📅 3. 课程表模块测试
### 3.1 后端测试
#### ScheduleServiceTest
测试覆盖范围:
- ✅ **创建日程成功** - 验证日程创建
- ❌ **用户不存在** - 验证用户验证
- ❌ **时间冲突** - 验证时间冲突检测
- ❌ **无效时间范围** - 验证时间校验
- ✅ **获取日程详情** - 验证详情获取
- ❌ **日程不存在** - 验证错误处理
- ❌ **无权限查看** - 验证权限控制
- ✅ **获取日程列表** - 验证列表获取
- ✅ **按时间范围获取** - 验证时间范围查询
- ✅ **更新日程** - 验证更新逻辑
- ❌ **无权限修改** - 验证权限控制
- ✅ **删除日程** - 验证删除逻辑
- ✅ **检查时间冲突** - 验证冲突检测算法
- ✅ **处理日程提醒** - 验证提醒逻辑
- ✅ **重复日程创建** - 验证重复日程逻辑
### 3.2 前端测试
#### schedule.test.ts
测试覆盖范围:
- 日程CRUD操作测试
- 时间冲突检测测试
- 重复日程测试
- 课程管理测试
## 👤 4. 用户模块测试
### 4.1 后端测试
#### UserServiceTest
测试覆盖范围:
- ✅ **用户注册成功** - 验证注册流程
- ❌ **用户名已存在** - 验证唯一性校验
- ❌ **邮箱已存在** - 验证邮箱唯一性
- ✅ **用户登录成功** - 验证登录流程和JWT生成
- ❌ **用户不存在** - 验证登录错误处理
- ❌ **密码错误** - 验证密码校验
- ❌ **账户被禁用** - 验证账户状态检查
- ✅ **获取用户信息** - 验证信息获取
- ❌ **用户不存在** - 验证错误处理
- ✅ **更新用户信息** - 验证信息更新
- ✅ **发送邮箱验证码** - 验证邮件发送
- ✅ **验证邮箱验证码** - 验证码校验
- ❌ **验证码过期** - 验证过期处理
- ❌ **验证码错误** - 验证错误处理
- ✅ **重置密码** - 验证密码重置
- ✅ **获取用户列表** - 验证管理功能
- ✅ **修改密码** - 验证密码修改
- ❌ **原密码错误** - 验证原密码校验
## 🔧 测试配置
### 测试环境配置
- **数据库**: H2内存数据库
- **Redis**: 测试专用实例
- **邮件**: Mock邮件服务
- **文件上传**: 临时目录
### 测试数据管理
- 使用 `TestDataBuilder` 统一创建测试数据
- 测试间数据隔离
- 自动清理测试数据
## 📊 测试覆盖率目标
| 模块 | 行覆盖率目标 | 分支覆盖率目标 |
|------|-------------|---------------|
| 论坛模块 | ≥ 85% | ≥ 80% |
| 资源模块 | ≥ 85% | ≥ 80% |
| 课程表模块 | ≥ 85% | ≥ 80% |
| 用户模块 | ≥ 90% | ≥ 85% |
## 🚀 运行测试
### 后端测试
```bash
# 运行所有测试
./run-tests.sh
# 运行特定模块测试
mvn test -Dtest="*PostServiceTest"
# 生成覆盖率报告
mvn test jacoco:report
```
### 前端测试
```bash
# 运行所有测试
npm run test
# 运行特定测试
npm run test -- forum.test.ts
# 生成覆盖率报告
npm run test:coverage
```
## 📝 测试最佳实践
### 测试命名规范
- 测试类:`{ClassName}Test`
- 测试方法:`test{MethodName}_{Scenario}`
- 示例:`testCreatePost_Success`、`testCreatePost_UserNotFound`
### 测试结构
使用 **AAA 模式**
1. **Arrange** - 准备测试数据和环境
2. **Act** - 执行被测试的方法
3. **Assert** - 验证结果
### Mock 使用原则
- 只Mock外部依赖
- 避免Mock被测试类的内部方法
- 使用合适的Mock策略严格vs宽松
### 测试数据管理
- 使用Builder模式创建测试数据
- 避免测试间的数据依赖
- 保持测试数据的简洁性
## 🔍 持续集成
### CI/CD流程
1. 代码提交触发测试
2. 并行运行单元测试和集成测试
3. 生成测试报告和覆盖率报告
4. 测试失败时阻止部署
### 测试报告
- 自动生成HTML格式测试报告
- 覆盖率报告可视化
- 失败测试详细信息
- 历史趋势分析
## 📋 总结
本测试设计方案为UniLife项目提供了全面的质量保障
1. **全面覆盖**: 涵盖所有核心业务逻辑
2. **边界测试**: 包含各种异常情况和边界条件
3. **权限验证**: 确保安全性要求
4. **性能考虑**: 验证关键操作的性能表现
5. **易于维护**: 结构清晰,便于后续维护和扩展
通过执行这套测试方案可以确保UniLife项目的稳定性、可靠性和安全性。

File diff suppressed because it is too large Load Diff

@ -10,13 +10,17 @@
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
"format": "prettier --write src/",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"element-plus": "^2.9.11",
"md-editor-v3": "^5.6.0",
"pinia": "^3.0.2",
"vue": "^3.5.15",
"vue-router": "^4.5.1"
@ -26,17 +30,22 @@
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.15.22",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0",
"jiti": "^2.4.2",
"jsdom": "^24.1.0",
"npm-run-all2": "^7.0.2",
"prettier": "3.5.3",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vitest": "^1.6.0",
"vue-tsc": "^2.2.8"
}
}

@ -0,0 +1,393 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import * as forumApi from '../forum'
// Mock axios
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
describe('Forum API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getPosts', () => {
it('should fetch posts successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: {
total: 10,
list: [
{
id: 1,
title: '测试帖子',
content: '测试内容',
categoryId: 1,
userId: 1,
likeCount: 5,
viewCount: 100,
commentCount: 3,
createdAt: '2024-01-01T10:00:00'
}
],
pages: 1
}
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await forumApi.getPosts({
categoryId: 1,
keyword: '测试',
page: 1,
size: 10,
sort: 'latest'
})
expect(mockedAxios.get).toHaveBeenCalledWith('/posts', {
params: {
categoryId: 1,
keyword: '测试',
page: 1,
size: 10,
sort: 'latest'
}
})
expect(result.data.data.list).toHaveLength(1)
expect(result.data.data.list[0].title).toBe('测试帖子')
})
it('should handle empty parameters', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: { total: 0, list: [], pages: 0 }
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
await forumApi.getPosts({})
expect(mockedAxios.get).toHaveBeenCalledWith('/posts', {
params: {}
})
})
})
describe('getPostDetail', () => {
it('should fetch post detail successfully', async () => {
const mockPost = {
id: 1,
title: '测试帖子详情',
content: '详细内容',
categoryId: 1,
userId: 1,
author: {
id: 1,
nickname: '作者',
avatar: 'avatar.jpg'
},
category: {
id: 1,
name: '学习讨论'
},
likeCount: 10,
viewCount: 200,
commentCount: 5,
isLiked: false,
createdAt: '2024-01-01T10:00:00'
}
const mockResponse = {
data: {
code: 200,
success: true,
data: mockPost
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await forumApi.getPostDetail(1)
expect(mockedAxios.get).toHaveBeenCalledWith('/posts/1')
expect(result.data.data.title).toBe('测试帖子详情')
expect(result.data.data.author.nickname).toBe('作者')
})
it('should handle post not found', async () => {
const mockResponse = {
data: {
code: 404,
success: false,
message: '帖子不存在'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await forumApi.getPostDetail(999)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('帖子不存在')
})
})
describe('createPost', () => {
it('should create post successfully', async () => {
const postData = {
title: '新帖子',
content: '新帖子内容',
categoryId: 1
}
const mockResponse = {
data: {
code: 200,
success: true,
data: { postId: 123 },
message: '帖子发布成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await forumApi.createPost(postData)
expect(mockedAxios.post).toHaveBeenCalledWith('/posts', postData)
expect(result.data.data.postId).toBe(123)
expect(result.data.message).toBe('帖子发布成功')
})
it('should handle validation errors', async () => {
const invalidPostData = {
title: '',
content: '内容',
categoryId: 1
}
const mockResponse = {
data: {
code: 400,
success: false,
message: '标题不能为空'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await forumApi.createPost(invalidPostData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('标题不能为空')
})
})
describe('updatePost', () => {
it('should update post successfully', async () => {
const updateData = {
title: '更新后的标题',
content: '更新后的内容',
categoryId: 2
}
const mockResponse = {
data: {
code: 200,
success: true,
message: '帖子更新成功'
}
}
mockedAxios.put.mockResolvedValue(mockResponse)
const result = await forumApi.updatePost(1, updateData)
expect(mockedAxios.put).toHaveBeenCalledWith('/posts/1', updateData)
expect(result.data.message).toBe('帖子更新成功')
})
})
describe('deletePost', () => {
it('should delete post successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
message: '帖子删除成功'
}
}
mockedAxios.delete.mockResolvedValue(mockResponse)
const result = await forumApi.deletePost(1)
expect(mockedAxios.delete).toHaveBeenCalledWith('/posts/1')
expect(result.data.message).toBe('帖子删除成功')
})
})
describe('likePost', () => {
it('should like post successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
message: '点赞成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await forumApi.likePost(1)
expect(mockedAxios.post).toHaveBeenCalledWith('/posts/1/like')
expect(result.data.message).toBe('点赞成功')
})
})
describe('getComments', () => {
it('should fetch comments successfully', async () => {
const mockComments = {
total: 2,
list: [
{
id: 1,
content: '评论内容1',
userId: 1,
nickname: '用户1',
avatar: 'avatar1.jpg',
likeCount: 3,
isLiked: false,
createdAt: '2024-01-01T10:00:00',
replies: []
},
{
id: 2,
content: '评论内容2',
userId: 2,
nickname: '用户2',
avatar: 'avatar2.jpg',
likeCount: 1,
isLiked: true,
createdAt: '2024-01-01T11:00:00',
replies: [
{
id: 3,
content: '回复内容',
userId: 1,
nickname: '用户1',
avatar: 'avatar1.jpg',
likeCount: 0,
isLiked: false,
createdAt: '2024-01-01T12:00:00'
}
]
}
]
}
const mockResponse = {
data: {
code: 200,
success: true,
data: mockComments
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await forumApi.getComments(1)
expect(mockedAxios.get).toHaveBeenCalledWith('/comments/post/1')
expect(result.data.data.list).toHaveLength(2)
expect(result.data.data.list[1].replies).toHaveLength(1)
})
})
describe('createComment', () => {
it('should create comment successfully', async () => {
const commentData = {
postId: 1,
content: '新评论内容',
parentId: null
}
const mockResponse = {
data: {
code: 200,
success: true,
data: { commentId: 456 },
message: '评论发布成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await forumApi.createComment(commentData)
expect(mockedAxios.post).toHaveBeenCalledWith('/comments', commentData)
expect(result.data.data.commentId).toBe(456)
})
it('should create reply successfully', async () => {
const replyData = {
postId: 1,
content: '回复内容',
parentId: 123
}
const mockResponse = {
data: {
code: 200,
success: true,
data: { commentId: 789 },
message: '回复发布成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await forumApi.createComment(replyData)
expect(mockedAxios.post).toHaveBeenCalledWith('/comments', replyData)
expect(result.data.data.commentId).toBe(789)
})
})
describe('getCategories', () => {
it('should fetch categories successfully', async () => {
const mockCategories = {
total: 3,
list: [
{ id: 1, name: '学习讨论', description: '学习相关话题', icon: 'book', sort: 1, status: 1 },
{ id: 2, name: '生活分享', description: '生活相关话题', icon: 'heart', sort: 2, status: 1 },
{ id: 3, name: '技术交流', description: '技术相关话题', icon: 'code', sort: 3, status: 1 }
]
}
const mockResponse = {
data: {
code: 200,
success: true,
data: mockCategories
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await forumApi.getCategories({ status: 1 })
expect(mockedAxios.get).toHaveBeenCalledWith('/categories', {
params: { status: 1 }
})
expect(result.data.data.list).toHaveLength(3)
expect(result.data.data.list[0].name).toBe('学习讨论')
})
})
})

@ -0,0 +1,484 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import * as resourcesApi from '../resources'
// Mock axios
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
describe('Resources API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getResources', () => {
it('should fetch resources successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: {
total: 5,
list: [
{
id: 1,
title: '测试资源',
description: '测试资源描述',
fileName: 'test.pdf',
fileUrl: 'http://example.com/test.pdf',
fileSize: 1024,
fileType: 'pdf',
categoryId: 1,
userId: 1,
downloadCount: 10,
likeCount: 5,
createdAt: '2024-01-01T10:00:00'
}
],
pages: 1
}
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.getResources({
category: 1,
user: 1,
keyword: '测试',
page: 1,
size: 10
})
expect(mockedAxios.get).toHaveBeenCalledWith('/resources', {
params: {
category: 1,
user: 1,
keyword: '测试',
page: 1,
size: 10
}
})
expect(result.data.data.list).toHaveLength(1)
expect(result.data.data.list[0].title).toBe('测试资源')
})
it('should handle empty parameters', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: { total: 0, list: [], pages: 0 }
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
await resourcesApi.getResources({})
expect(mockedAxios.get).toHaveBeenCalledWith('/resources', {
params: {}
})
})
})
describe('getResourceDetail', () => {
it('should fetch resource detail successfully', async () => {
const mockResource = {
id: 1,
title: '测试资源详情',
description: '详细描述',
fileName: 'test.pdf',
fileUrl: 'http://example.com/test.pdf',
fileSize: 2048,
fileType: 'pdf',
categoryId: 1,
userId: 1,
author: {
id: 1,
nickname: '作者',
avatar: 'avatar.jpg'
},
category: {
id: 1,
name: '学习资料'
},
downloadCount: 20,
likeCount: 10,
isLiked: false,
createdAt: '2024-01-01T10:00:00'
}
const mockResponse = {
data: {
code: 200,
success: true,
data: mockResource
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.getResourceDetail(1)
expect(mockedAxios.get).toHaveBeenCalledWith('/resources/1')
expect(result.data.data.title).toBe('测试资源详情')
expect(result.data.data.author.nickname).toBe('作者')
})
it('should handle resource not found', async () => {
const mockResponse = {
data: {
code: 404,
success: false,
message: '资源不存在'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.getResourceDetail(999)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('资源不存在')
})
})
describe('uploadResource', () => {
it('should upload resource successfully', async () => {
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
const resourceData = {
title: '新资源',
description: '新资源描述',
categoryId: 1
}
const mockResponse = {
data: {
code: 200,
success: true,
data: { resourceId: 123 },
message: '资源上传成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await resourcesApi.uploadResource(file, resourceData)
expect(mockedAxios.post).toHaveBeenCalledWith(
'/resources',
expect.any(FormData),
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
expect(result.data.data.resourceId).toBe(123)
expect(result.data.message).toBe('资源上传成功')
})
it('should handle file validation errors', async () => {
const file = new File([''], 'empty.pdf', { type: 'application/pdf' })
const resourceData = {
title: '新资源',
description: '新资源描述',
categoryId: 1
}
const mockResponse = {
data: {
code: 400,
success: false,
message: '文件不能为空'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await resourcesApi.uploadResource(file, resourceData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('文件不能为空')
})
it('should handle unsupported file type', async () => {
const file = new File(['test content'], 'test.exe', { type: 'application/octet-stream' })
const resourceData = {
title: '新资源',
description: '新资源描述',
categoryId: 1
}
const mockResponse = {
data: {
code: 400,
success: false,
message: '不支持的文件类型'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await resourcesApi.uploadResource(file, resourceData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('不支持的文件类型')
})
})
describe('updateResource', () => {
it('should update resource successfully', async () => {
const updateData = {
title: '更新后的标题',
description: '更新后的描述',
categoryId: 2
}
const mockResponse = {
data: {
code: 200,
success: true,
message: '资源更新成功'
}
}
mockedAxios.put.mockResolvedValue(mockResponse)
const result = await resourcesApi.updateResource(1, updateData)
expect(mockedAxios.put).toHaveBeenCalledWith('/resources/1', updateData)
expect(result.data.message).toBe('资源更新成功')
})
it('should handle unauthorized update', async () => {
const updateData = {
title: '更新后的标题',
description: '更新后的描述',
categoryId: 2
}
const mockResponse = {
data: {
code: 403,
success: false,
message: '无权限修改此资源'
}
}
mockedAxios.put.mockResolvedValue(mockResponse)
const result = await resourcesApi.updateResource(1, updateData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('无权限修改此资源')
})
})
describe('deleteResource', () => {
it('should delete resource successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
message: '资源删除成功'
}
}
mockedAxios.delete.mockResolvedValue(mockResponse)
const result = await resourcesApi.deleteResource(1)
expect(mockedAxios.delete).toHaveBeenCalledWith('/resources/1')
expect(result.data.message).toBe('资源删除成功')
})
})
describe('downloadResource', () => {
it('should download resource successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: {
downloadUrl: 'http://example.com/download/test.pdf'
},
message: '获取下载链接成功'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.downloadResource(1)
expect(mockedAxios.get).toHaveBeenCalledWith('/resources/1/download')
expect(result.data.data.downloadUrl).toBe('http://example.com/download/test.pdf')
})
it('should handle download permission denied', async () => {
const mockResponse = {
data: {
code: 403,
success: false,
message: '无权限下载此资源'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.downloadResource(1)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('无权限下载此资源')
})
})
describe('likeResource', () => {
it('should like resource successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
message: '点赞成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await resourcesApi.likeResource(1)
expect(mockedAxios.post).toHaveBeenCalledWith('/resources/1/like')
expect(result.data.message).toBe('点赞成功')
})
it('should unlike resource successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
message: '取消点赞成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await resourcesApi.likeResource(1)
expect(result.data.message).toBe('取消点赞成功')
})
})
describe('getUserResources', () => {
it('should fetch user resources successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: {
total: 3,
list: [
{
id: 1,
title: '用户资源1',
description: '描述1',
fileName: 'file1.pdf',
fileType: 'pdf',
downloadCount: 5,
likeCount: 2,
createdAt: '2024-01-01T10:00:00'
},
{
id: 2,
title: '用户资源2',
description: '描述2',
fileName: 'file2.docx',
fileType: 'docx',
downloadCount: 8,
likeCount: 3,
createdAt: '2024-01-02T10:00:00'
}
],
pages: 1
}
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.getUserResources(1, {
page: 1,
size: 10
})
expect(mockedAxios.get).toHaveBeenCalledWith('/resources/user/1', {
params: {
page: 1,
size: 10
}
})
expect(result.data.data.list).toHaveLength(2)
expect(result.data.data.list[0].title).toBe('用户资源1')
})
})
describe('getMyResources', () => {
it('should fetch current user resources successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: {
total: 2,
list: [
{
id: 1,
title: '我的资源1',
description: '我的描述1',
fileName: 'myfile1.pdf',
fileType: 'pdf',
downloadCount: 3,
likeCount: 1,
createdAt: '2024-01-01T10:00:00'
}
],
pages: 1
}
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.getMyResources({
page: 1,
size: 10
})
expect(mockedAxios.get).toHaveBeenCalledWith('/resources/my', {
params: {
page: 1,
size: 10
}
})
expect(result.data.data.list).toHaveLength(1)
expect(result.data.data.list[0].title).toBe('我的资源1')
})
it('should handle unauthorized access', async () => {
const mockResponse = {
data: {
code: 401,
success: false,
message: '未登录'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await resourcesApi.getMyResources({})
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('未登录')
})
})
})

@ -0,0 +1,617 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import * as scheduleApi from '../schedule'
// Mock axios
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
describe('Schedule API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getSchedules', () => {
it('should fetch schedules successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: [
{
id: 1,
title: '高等数学',
description: '线性代数课程',
startTime: '2024-01-15T09:00:00',
endTime: '2024-01-15T10:30:00',
location: '教学楼A101',
type: 'COURSE',
repeatType: 'WEEKLY',
repeatEnd: '2024-06-15T10:30:00',
userId: 1,
createdAt: '2024-01-01T10:00:00'
},
{
id: 2,
title: '团队会议',
description: '项目进度讨论',
startTime: '2024-01-16T14:00:00',
endTime: '2024-01-16T15:00:00',
location: '会议室B201',
type: 'MEETING',
repeatType: 'NONE',
userId: 1,
createdAt: '2024-01-01T11:00:00'
}
]
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getSchedules()
expect(mockedAxios.get).toHaveBeenCalledWith('/schedules')
expect(result.data.data).toHaveLength(2)
expect(result.data.data[0].title).toBe('高等数学')
expect(result.data.data[1].type).toBe('MEETING')
})
it('should handle empty schedule list', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: []
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getSchedules()
expect(result.data.data).toHaveLength(0)
})
})
describe('getSchedulesByRange', () => {
it('should fetch schedules by time range successfully', async () => {
const startTime = '2024-01-01T00:00:00'
const endTime = '2024-01-31T23:59:59'
const mockResponse = {
data: {
code: 200,
success: true,
data: [
{
id: 1,
title: '期末考试',
description: '高等数学期末考试',
startTime: '2024-01-20T09:00:00',
endTime: '2024-01-20T11:00:00',
location: '考试教室C301',
type: 'EXAM',
repeatType: 'NONE',
userId: 1
}
]
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getSchedulesByRange(startTime, endTime)
expect(mockedAxios.get).toHaveBeenCalledWith('/schedules/range', {
params: {
startTime,
endTime
}
})
expect(result.data.data).toHaveLength(1)
expect(result.data.data[0].type).toBe('EXAM')
})
it('should handle invalid time range', async () => {
const startTime = '2024-01-31T23:59:59'
const endTime = '2024-01-01T00:00:00'
const mockResponse = {
data: {
code: 400,
success: false,
message: '结束时间不能早于开始时间'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getSchedulesByRange(startTime, endTime)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('结束时间不能早于开始时间')
})
})
describe('getScheduleDetail', () => {
it('should fetch schedule detail successfully', async () => {
const mockSchedule = {
id: 1,
title: '数据结构与算法',
description: '数据结构课程,包含树、图等内容',
startTime: '2024-01-15T14:00:00',
endTime: '2024-01-15T15:30:00',
location: '计算机楼201',
type: 'COURSE',
repeatType: 'WEEKLY',
repeatEnd: '2024-06-15T15:30:00',
userId: 1,
createdAt: '2024-01-01T10:00:00',
updatedAt: '2024-01-01T10:00:00'
}
const mockResponse = {
data: {
code: 200,
success: true,
data: mockSchedule
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getScheduleDetail(1)
expect(mockedAxios.get).toHaveBeenCalledWith('/schedules/1')
expect(result.data.data.title).toBe('数据结构与算法')
expect(result.data.data.location).toBe('计算机楼201')
})
it('should handle schedule not found', async () => {
const mockResponse = {
data: {
code: 404,
success: false,
message: '日程不存在'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getScheduleDetail(999)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('日程不存在')
})
it('should handle unauthorized access', async () => {
const mockResponse = {
data: {
code: 403,
success: false,
message: '无权限查看此日程'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getScheduleDetail(1)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('无权限查看此日程')
})
})
describe('createSchedule', () => {
it('should create schedule successfully', async () => {
const scheduleData = {
title: '新课程',
description: '新课程描述',
startTime: '2024-01-16T09:00:00',
endTime: '2024-01-16T10:30:00',
location: '教学楼D101',
type: 'COURSE',
repeatType: 'WEEKLY',
repeatEnd: '2024-06-16T10:30:00'
}
const mockResponse = {
data: {
code: 200,
success: true,
data: { scheduleId: 123 },
message: '日程创建成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await scheduleApi.createSchedule(scheduleData)
expect(mockedAxios.post).toHaveBeenCalledWith('/schedules', scheduleData)
expect(result.data.data.scheduleId).toBe(123)
expect(result.data.message).toBe('日程创建成功')
})
it('should handle validation errors', async () => {
const invalidScheduleData = {
title: '',
description: '描述',
startTime: '2024-01-16T09:00:00',
endTime: '2024-01-16T08:00:00', // 结束时间早于开始时间
location: '教学楼D101',
type: 'COURSE',
repeatType: 'WEEKLY'
}
const mockResponse = {
data: {
code: 400,
success: false,
message: '结束时间不能早于开始时间'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await scheduleApi.createSchedule(invalidScheduleData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('结束时间不能早于开始时间')
})
it('should handle time conflict', async () => {
const conflictingScheduleData = {
title: '冲突课程',
description: '与现有课程时间冲突',
startTime: '2024-01-16T09:30:00',
endTime: '2024-01-16T11:00:00',
location: '教学楼D102',
type: 'COURSE',
repeatType: 'WEEKLY'
}
const mockResponse = {
data: {
code: 400,
success: false,
message: '时间冲突,与现有日程重叠'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await scheduleApi.createSchedule(conflictingScheduleData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('时间冲突,与现有日程重叠')
})
})
describe('updateSchedule', () => {
it('should update schedule successfully', async () => {
const updateData = {
title: '更新后的课程',
description: '更新后的描述',
startTime: '2024-01-16T10:00:00',
endTime: '2024-01-16T11:30:00',
location: '教学楼E101',
type: 'COURSE',
repeatType: 'WEEKLY',
repeatEnd: '2024-06-16T11:30:00'
}
const mockResponse = {
data: {
code: 200,
success: true,
message: '日程更新成功'
}
}
mockedAxios.put.mockResolvedValue(mockResponse)
const result = await scheduleApi.updateSchedule(1, updateData)
expect(mockedAxios.put).toHaveBeenCalledWith('/schedules/1', updateData)
expect(result.data.message).toBe('日程更新成功')
})
it('should handle unauthorized update', async () => {
const updateData = {
title: '尝试更新别人的课程',
description: '无权限更新',
startTime: '2024-01-16T10:00:00',
endTime: '2024-01-16T11:30:00',
location: '教学楼E101',
type: 'COURSE'
}
const mockResponse = {
data: {
code: 403,
success: false,
message: '无权限修改此日程'
}
}
mockedAxios.put.mockResolvedValue(mockResponse)
const result = await scheduleApi.updateSchedule(1, updateData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('无权限修改此日程')
})
})
describe('deleteSchedule', () => {
it('should delete schedule successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
message: '日程删除成功'
}
}
mockedAxios.delete.mockResolvedValue(mockResponse)
const result = await scheduleApi.deleteSchedule(1)
expect(mockedAxios.delete).toHaveBeenCalledWith('/schedules/1')
expect(result.data.message).toBe('日程删除成功')
})
it('should handle unauthorized delete', async () => {
const mockResponse = {
data: {
code: 403,
success: false,
message: '无权限删除此日程'
}
}
mockedAxios.delete.mockResolvedValue(mockResponse)
const result = await scheduleApi.deleteSchedule(1)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('无权限删除此日程')
})
})
describe('checkConflict', () => {
it('should check for no conflicts', async () => {
const startTime = '2024-01-16T13:00:00'
const endTime = '2024-01-16T14:30:00'
const mockResponse = {
data: {
code: 200,
success: true,
message: '无时间冲突'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.checkConflict(startTime, endTime)
expect(mockedAxios.get).toHaveBeenCalledWith('/schedules/check-conflict', {
params: {
startTime,
endTime
}
})
expect(result.data.message).toBe('无时间冲突')
})
it('should check for conflicts with exclusion', async () => {
const startTime = '2024-01-16T13:00:00'
const endTime = '2024-01-16T14:30:00'
const excludeScheduleId = 5
const mockResponse = {
data: {
code: 200,
success: true,
message: '无时间冲突'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.checkConflict(startTime, endTime, excludeScheduleId)
expect(mockedAxios.get).toHaveBeenCalledWith('/schedules/check-conflict', {
params: {
startTime,
endTime,
excludeScheduleId
}
})
expect(result.data.message).toBe('无时间冲突')
})
it('should detect time conflicts', async () => {
const startTime = '2024-01-16T09:30:00'
const endTime = '2024-01-16T11:00:00'
const mockResponse = {
data: {
code: 400,
success: false,
message: '时间冲突,与现有日程重叠'
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.checkConflict(startTime, endTime)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('时间冲突,与现有日程重叠')
})
})
describe('getCourses', () => {
it('should fetch courses successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
data: [
{
id: 1,
name: '高等数学',
code: 'MATH101',
teacher: '张教授',
credits: 4,
semester: '2024春季',
description: '高等数学基础课程'
},
{
id: 2,
name: '数据结构',
code: 'CS201',
teacher: '李教授',
credits: 3,
semester: '2024春季',
description: '计算机科学基础课程'
}
]
}
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await scheduleApi.getCourses()
expect(mockedAxios.get).toHaveBeenCalledWith('/courses')
expect(result.data.data).toHaveLength(2)
expect(result.data.data[0].name).toBe('高等数学')
expect(result.data.data[1].credits).toBe(3)
})
})
describe('createCourse', () => {
it('should create course successfully', async () => {
const courseData = {
name: '操作系统',
code: 'CS301',
teacher: '王教授',
credits: 3,
semester: '2024春季',
description: '操作系统原理与实践'
}
const mockResponse = {
data: {
code: 200,
success: true,
data: { courseId: 456 },
message: '课程创建成功'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await scheduleApi.createCourse(courseData)
expect(mockedAxios.post).toHaveBeenCalledWith('/courses', courseData)
expect(result.data.data.courseId).toBe(456)
expect(result.data.message).toBe('课程创建成功')
})
it('should handle duplicate course code', async () => {
const duplicateCourseData = {
name: '重复课程',
code: 'MATH101', // 已存在的课程代码
teacher: '赵教授',
credits: 2,
semester: '2024春季',
description: '重复的课程代码'
}
const mockResponse = {
data: {
code: 400,
success: false,
message: '课程代码已存在'
}
}
mockedAxios.post.mockResolvedValue(mockResponse)
const result = await scheduleApi.createCourse(duplicateCourseData)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('课程代码已存在')
})
})
describe('updateCourse', () => {
it('should update course successfully', async () => {
const updateData = {
name: '高等数学(更新)',
code: 'MATH101',
teacher: '张教授',
credits: 4,
semester: '2024春季',
description: '更新后的高等数学课程'
}
const mockResponse = {
data: {
code: 200,
success: true,
message: '课程更新成功'
}
}
mockedAxios.put.mockResolvedValue(mockResponse)
const result = await scheduleApi.updateCourse(1, updateData)
expect(mockedAxios.put).toHaveBeenCalledWith('/courses/1', updateData)
expect(result.data.message).toBe('课程更新成功')
})
})
describe('deleteCourse', () => {
it('should delete course successfully', async () => {
const mockResponse = {
data: {
code: 200,
success: true,
message: '课程删除成功'
}
}
mockedAxios.delete.mockResolvedValue(mockResponse)
const result = await scheduleApi.deleteCourse(1)
expect(mockedAxios.delete).toHaveBeenCalledWith('/courses/1')
expect(result.data.message).toBe('课程删除成功')
})
it('should handle course in use error', async () => {
const mockResponse = {
data: {
code: 400,
success: false,
message: '课程正在使用中,无法删除'
}
}
mockedAxios.delete.mockResolvedValue(mockResponse)
const result = await scheduleApi.deleteCourse(1)
expect(result.data.success).toBe(false)
expect(result.data.message).toBe('课程正在使用中,无法删除')
})
})
})

@ -220,46 +220,95 @@
<el-dialog
v-model="showCreatePost"
title="发布新帖子"
width="600px"
width="800px"
class="create-post-dialog"
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form-item label="帖子标题">
<el-input
v-model="postForm.title"
placeholder="请输入帖子标题"
size="large"
/>
</el-form-item>
<el-form-item label="分类">
<el-select
v-model="postForm.categoryId"
placeholder="请选择分类"
size="large"
style="width: 100%"
<div class="create-post-form">
<el-form label-position="top" :model="postForm" ref="postFormRef">
<div class="form-row">
<el-form-item
label="帖子标题"
prop="title"
:rules="[{ required: true, message: '请输入帖子标题', trigger: 'blur' }]"
class="title-field"
>
<el-input
v-model="postForm.title"
placeholder="写一个吸引人的标题..."
size="large"
maxlength="100"
show-word-limit
clearable
/>
</el-form-item>
<el-form-item
label="分类"
prop="categoryId"
:rules="[{ required: true, message: '请选择分类', trigger: 'change' }]"
class="category-field"
>
<el-select
v-model="postForm.categoryId"
placeholder="选择分类"
size="large"
style="width: 100%"
clearable
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</el-form-item>
</div>
<el-form-item
label="帖子内容"
prop="content"
:rules="[{ required: true, message: '请输入帖子内容', trigger: 'blur' }]"
class="content-field"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</el-form-item>
<el-form-item label="帖子内容">
<el-input
v-model="postForm.content"
type="textarea"
:rows="6"
placeholder="请输入帖子内容..."
/>
</el-form-item>
</el-form>
<div class="markdown-editor-container">
<MdEditor
v-model="postForm.content"
:height="400"
:preview="true"
placeholder="支持Markdown语法让你的内容更生动..."
theme="light"
preview-theme="default"
code-theme="atom"
/>
</div>
</el-form-item>
<div class="form-tips">
<div class="tip-item">
<el-icon><InfoFilled /></el-icon>
<span>支持Markdown语法**粗体***斜体*`代码`[链接](url)</span>
</div>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="showCreatePost = false" :disabled="posting">取消</el-button>
<el-button type="primary" @click="handleCreatePost" :loading="posting">
{{ posting ? '发布中...' : '发布' }}
</el-button>
<div class="dialog-footer">
<el-button @click="showCreatePost = false" :disabled="posting" size="large">
取消
</el-button>
<el-button
type="primary"
@click="handleCreatePost"
:loading="posting"
size="large"
class="publish-btn"
>
<el-icon v-if="!posting"><EditPen /></el-icon>
{{ posting ? '发布中...' : '发布帖子' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
@ -277,11 +326,15 @@ import {
ChatDotRound,
Star,
Setting,
School
School,
EditPen,
InfoFilled
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { getPosts, getCategories, createPost, likePost } from '@/api/forum'
import type { Post, Category, ApiResponse } from '@/types'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
const router = useRouter()
const userStore = useUserStore()
@ -293,6 +346,7 @@ const selectedCategory = ref<number | null>(null)
const sortBy = ref('latest')
const loading = ref(false)
const posting = ref(false)
const postFormRef = ref()
//
const categories = ref<Category[]>([])
@ -372,6 +426,17 @@ const toggleLike = async (post: Post) => {
}
const handleCreatePost = async () => {
//
if (!postFormRef.value) return
try {
const valid = await postFormRef.value.validate()
if (!valid) return
} catch (error) {
console.log('表单验证失败:', error)
return
}
if (!postForm.title.trim()) {
ElMessage.warning('请输入帖子标题')
return
@ -388,8 +453,8 @@ const handleCreatePost = async () => {
try {
posting.value = true
const response = await createPost({
title: postForm.title,
content: postForm.content,
title: postForm.title.trim(),
content: postForm.content.trim(),
categoryId: postForm.categoryId
}) as any as ApiResponse<{ postId: number }>
@ -400,6 +465,10 @@ const handleCreatePost = async () => {
postForm.title = ''
postForm.content = ''
postForm.categoryId = null
//
if (postFormRef.value) {
postFormRef.value.resetFields()
}
//
loadPosts()
}
@ -850,4 +919,163 @@ onMounted(() => {
justify-content: center;
}
}
/* 点赞按钮优化样式 */
.post-actions .el-button {
border: none !important;
background: transparent !important;
color: var(--gray-500) !important;
font-weight: 500 !important;
transition: var(--transition-base) !important;
padding: 8px 12px !important;
border-radius: 8px !important;
}
.post-actions .el-button:hover {
background: rgba(168, 85, 247, 0.1) !important;
color: var(--primary-600) !important;
transform: translateY(-1px) !important;
}
.post-actions .el-button.el-button--primary {
background: rgba(168, 85, 247, 0.15) !important;
color: var(--primary-600) !important;
font-weight: 600 !important;
}
.post-actions .el-button.el-button--primary:hover {
background: rgba(168, 85, 247, 0.2) !important;
color: var(--primary-700) !important;
}
.post-actions .el-button .el-icon {
margin-right: 4px !important;
}
.create-post-dialog {
border-radius: 16px !important;
}
.create-post-dialog .el-dialog__header {
padding: 24px 24px 16px !important;
background: var(--gradient-light) !important;
border-bottom: 1px solid var(--gray-200) !important;
}
.create-post-dialog .el-dialog__title {
font-size: 20px !important;
font-weight: 700 !important;
color: var(--gray-800) !important;
}
.create-post-dialog .el-dialog__body {
padding: 24px !important;
background: var(--gray-50) !important;
}
.create-post-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 200px;
gap: 16px;
align-items: start;
}
.title-field {
grid-column: 1;
}
.category-field {
grid-column: 2;
}
.content-field .el-form-item__label {
font-size: 16px !important;
font-weight: 600 !important;
color: var(--gray-800) !important;
margin-bottom: 12px !important;
}
.markdown-editor-container {
border-radius: 12px;
overflow: hidden;
border: 2px solid var(--gray-200);
transition: var(--transition-base);
}
.markdown-editor-container:hover {
border-color: var(--primary-300);
}
.markdown-editor-container:focus-within {
border-color: var(--primary-500);
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.1);
}
.form-tips {
background: rgba(168, 85, 247, 0.05);
border: 1px solid rgba(168, 85, 247, 0.2);
border-radius: 12px;
padding: 16px;
}
.tip-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--primary-700);
font-size: 14px;
}
.tip-item .el-icon {
color: var(--primary-500);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px 24px !important;
background: var(--gray-50) !important;
border-top: 1px solid var(--gray-200) !important;
}
.publish-btn {
background: var(--gradient-primary) !important;
border: none !important;
font-weight: 600 !important;
padding: 12px 24px !important;
border-radius: 12px !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
.publish-btn:hover {
transform: translateY(-2px) !important;
box-shadow: var(--shadow-purple) !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.create-post-dialog {
width: 95% !important;
margin: 20px auto !important;
}
.form-row {
grid-template-columns: 1fr;
gap: 16px;
}
.title-field,
.category-field {
grid-column: 1;
}
}
</style>

@ -16,6 +16,7 @@
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
</div>
<div class="nav-actions">
@ -84,11 +85,19 @@
<div class="author-stats">发帖 {{ authorStats.postCount }} · 获赞 {{ authorStats.likeCount }}</div>
</div>
</div>
<el-button type="primary" plain size="small">关注</el-button>
<el-button type="primary" plain size="small" class="follow-btn">关注</el-button>
</div>
<div class="post-content">
<pre class="post-text">{{ post.content }}</pre>
<div class="markdown-content">
<MdPreview
:model-value="post.content"
preview-theme="default"
code-theme="atom"
:show-code-row-number="true"
class="post-markdown"
/>
</div>
</div>
<div class="post-footer">
@ -219,6 +228,8 @@ import {
import { useUserStore } from '@/stores/user'
import { getPostDetail, likePost as likePostAPI, getComments, createComment as createCommentAPI, likeComment as likeCommentAPI } from '@/api/forum'
import type { Post, ApiResponse } from '@/types'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
const router = useRouter()
const route = useRoute()
@ -570,36 +581,169 @@ onMounted(async () => {
font-size: 14px;
}
/* 关注按钮优化样式 */
.follow-btn {
background: transparent !important;
border: 2px solid var(--primary-300) !important;
color: var(--primary-600) !important;
padding: 8px 16px !important;
border-radius: 20px !important;
font-weight: 500 !important;
transition: var(--transition-base) !important;
min-width: 80px !important;
}
.follow-btn:hover {
background: var(--primary-50) !important;
border-color: var(--primary-400) !important;
color: var(--primary-700) !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.2) !important;
}
.follow-btn:active {
transform: translateY(0) !important;
}
/* 已关注状态样式 */
.follow-btn.following {
background: var(--primary-100) !important;
border-color: var(--primary-400) !important;
color: var(--primary-700) !important;
}
.follow-btn.following:hover {
background: var(--gray-100) !important;
border-color: var(--gray-400) !important;
color: var(--gray-600) !important;
}
.post-content {
color: var(--gray-700);
line-height: 1.8;
font-size: 16px;
margin-bottom: 32px;
}
.post-content p {
margin-bottom: 16px;
.markdown-content {
background: white;
border-radius: 12px;
padding: 24px;
border: 1px solid var(--gray-200);
box-shadow: var(--shadow-sm);
}
.post-markdown {
font-size: 16px !important;
line-height: 1.8 !important;
color: var(--gray-700) !important;
}
.post-content ul {
margin: 16px 0;
padding-left: 24px;
/* Markdown内容样式优化 */
.post-markdown h1,
.post-markdown h2,
.post-markdown h3,
.post-markdown h4,
.post-markdown h5,
.post-markdown h6 {
color: var(--gray-800) !important;
font-weight: 600 !important;
margin: 24px 0 16px 0 !important;
}
.post-content li {
margin-bottom: 8px;
.post-markdown h1 {
font-size: 28px !important;
border-bottom: 2px solid var(--primary-200) !important;
padding-bottom: 8px !important;
}
.post-markdown h2 {
font-size: 24px !important;
border-bottom: 1px solid var(--gray-200) !important;
padding-bottom: 6px !important;
}
.post-markdown h3 {
font-size: 20px !important;
}
.post-markdown p {
margin-bottom: 16px !important;
color: var(--gray-700) !important;
}
.post-markdown blockquote {
border-left: 4px solid var(--primary-400) !important;
background: var(--primary-50) !important;
padding: 16px 20px !important;
margin: 16px 0 !important;
border-radius: 6px !important;
}
.post-markdown code {
background: var(--gray-100) !important;
padding: 2px 6px !important;
border-radius: 4px !important;
font-size: 14px !important;
color: var(--primary-700) !important;
}
.post-markdown pre {
background: var(--gray-900) !important;
border-radius: 8px !important;
padding: 16px !important;
margin: 16px 0 !important;
overflow-x: auto !important;
}
.post-markdown ul,
.post-markdown ol {
margin: 16px 0 !important;
padding-left: 24px !important;
}
.post-markdown li {
margin-bottom: 8px !important;
color: var(--gray-700) !important;
}
.post-markdown table {
width: 100% !important;
border-collapse: collapse !important;
margin: 16px 0 !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: var(--shadow-sm) !important;
}
.post-markdown th,
.post-markdown td {
padding: 12px 16px !important;
text-align: left !important;
border-bottom: 1px solid var(--gray-200) !important;
}
.post-markdown th {
background: var(--primary-50) !important;
font-weight: 600 !important;
color: var(--gray-800) !important;
}
.post-markdown a {
color: var(--primary-600) !important;
text-decoration: none !important;
border-bottom: 1px solid transparent !important;
transition: var(--transition-base) !important;
}
.post-markdown a:hover {
color: var(--primary-700) !important;
border-bottom-color: var(--primary-400) !important;
}
.post-text {
white-space: pre-wrap;
word-wrap: break-word;
font-family: inherit;
margin: 0;
background: none;
border: none;
font-size: inherit;
color: inherit;
line-height: inherit;
.post-markdown img {
max-width: 100% !important;
height: auto !important;
border-radius: 8px !important;
margin: 16px 0 !important;
box-shadow: var(--shadow-md) !important;
}
.post-footer {

@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.d.ts',
'**/*.config.*',
'**/coverage/**'
]
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})

@ -131,6 +131,48 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- MockMvc for web layer testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
<scope>test</scope>
</dependency>
<!-- Mockito for mocking -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<!-- H2 Database for testing -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- TestContainers for integration testing -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>

@ -80,56 +80,79 @@ public class CommentServiceImpl implements CommentService {
@Override
public Result getCommentsByPostId(Long postId, Long userId) {
log.info("获取帖子 {} 的评论列表,当前用户: {}", postId, userId);
// 检查帖子是否存在
Post post = postMapper.getById(postId);
if (post == null) {
log.warn("帖子 {} 不存在", postId);
return Result.error(404, "帖子不存在");
}
// 获取一级评论
List<Comment> topLevelComments = commentMapper.getTopLevelCommentsByPostId(postId);
log.info("获取到 {} 条一级评论", topLevelComments.size());
// 转换为VO
List<CommentVO> commentVOs = topLevelComments.stream().map(comment -> {
// 获取评论用户信息
User user = userMapper.getUserById(comment.getUserId());
// 获取回复列表
List<Comment> replies = commentMapper.getRepliesByParentId(comment.getId());
List<CommentVO> replyVOs = replies.stream().map(reply -> {
User replyUser = userMapper.getUserById(reply.getUserId());
return CommentVO.builder()
.id(reply.getId())
.postId(reply.getPostId())
.userId(reply.getUserId())
.nickname(replyUser != null ? replyUser.getNickname() : "未知用户")
.avatar(replyUser != null ? replyUser.getAvatar() : null)
.content(reply.getContent())
.parentId(reply.getParentId())
.likeCount(reply.getLikeCount())
List<CommentVO> commentVOs = new ArrayList<>();
for (Comment comment : topLevelComments) {
try {
// 获取评论用户信息
User user = userMapper.getUserById(comment.getUserId());
log.debug("评论 {} 的用户信息: {}", comment.getId(), user != null ? user.getNickname() : "null");
// 获取回复列表
List<Comment> replies = commentMapper.getRepliesByParentId(comment.getId());
List<CommentVO> replyVOs = new ArrayList<>();
for (Comment reply : replies) {
try {
User replyUser = userMapper.getUserById(reply.getUserId());
log.debug("回复 {} 的用户信息: {}", reply.getId(), replyUser != null ? replyUser.getNickname() : "null");
CommentVO replyVO = CommentVO.builder()
.id(reply.getId())
.postId(reply.getPostId())
.userId(reply.getUserId())
.nickname(replyUser != null ? replyUser.getNickname() : "未知用户")
.avatar(replyUser != null ? replyUser.getAvatar() : null)
.content(reply.getContent())
.parentId(reply.getParentId())
.likeCount(reply.getLikeCount())
.isLiked(false) // 实际开发中应该查询用户是否点赞
.createdAt(reply.getCreatedAt())
.replies(new ArrayList<>())
.build();
replyVOs.add(replyVO);
} catch (Exception e) {
log.error("处理回复 {} 时出错: {}", reply.getId(), e.getMessage());
}
}
CommentVO commentVO = CommentVO.builder()
.id(comment.getId())
.postId(comment.getPostId())
.userId(comment.getUserId())
.nickname(user != null ? user.getNickname() : "未知用户")
.avatar(user != null ? user.getAvatar() : null)
.content(comment.getContent())
.parentId(comment.getParentId())
.likeCount(comment.getLikeCount())
.isLiked(false) // 实际开发中应该查询用户是否点赞
.createdAt(reply.getCreatedAt())
.replies(new ArrayList<>())
.createdAt(comment.getCreatedAt())
.replies(replyVOs)
.build();
}).collect(Collectors.toList());
return CommentVO.builder()
.id(comment.getId())
.postId(comment.getPostId())
.userId(comment.getUserId())
.nickname(user != null ? user.getNickname() : "未知用户")
.avatar(user != null ? user.getAvatar() : null)
.content(comment.getContent())
.parentId(comment.getParentId())
.likeCount(comment.getLikeCount())
.isLiked(false) // 实际开发中应该查询用户是否点赞
.createdAt(comment.getCreatedAt())
.replies(replyVOs)
.build();
}).collect(Collectors.toList());
commentVOs.add(commentVO);
} catch (Exception e) {
log.error("处理评论 {} 时出错: {}", comment.getId(), e.getMessage());
}
}
// 获取评论总数
Integer count = commentMapper.getCountByPostId(postId);
log.info("帖子 {} 的评论总数: {}, 实际返回: {}", postId, count, commentVOs.size());
// 返回结果
Map<String, Object> data = new HashMap<>();

@ -261,39 +261,39 @@ INSERT INTO `users` (`username`, `email`, `password`, `nickname`, `bio`, `gender
('zhoujie_history', 'zhoujie@whu.edu.cn', '123456', '史学研究生', '历史学院研究生,中国古代史方向,博物馆志愿者', 1, '2024302050001', '历史学院', '中国史', '2024级', 100, 0, 1, 1, '2024-09-01 22:00:00'),
('tanglei_news', 'tanglei@whu.edu.cn', '123456', '新传人', '新闻与传播学院2021级新闻学校媒记者关注社会热点', 1, '2021301070001', '新闻与传播学院', '新闻学', '2021级', 170, 0, 1, 1, '2024-09-01 23:00:00');
-- 插入论坛帖子数据使用已存在的用户ID
-- 插入论坛帖子数据使用已存在的用户ID初始计数设为0
INSERT INTO `posts` (`user_id`, `category_id`, `title`, `content`, `view_count`, `like_count`, `comment_count`, `status`, `created_at`) VALUES
-- 学术交流类帖子
(2, 1, '数学建模美赛经验分享', '刚刚结束的美国大学生数学建模竞赛我们团队获得了M奖分享一下参赛经验和技巧希望对学弟学妹们有帮助。数模比赛不仅考验数学能力更重要的是团队协作和论文写作能力。首先要选择合适的队友最好是数学、编程、英语各有所长的组合...', 256, 42, 8, 2, '2024-12-20 09:30:00'),
(2, 1, '数学建模美赛经验分享', '刚刚结束的美国大学生数学建模竞赛我们团队获得了M奖分享一下参赛经验和技巧希望对学弟学妹们有帮助。数模比赛不仅考验数学能力更重要的是团队协作和论文写作能力。首先要选择合适的队友最好是数学、编程、英语各有所长的组合...', 256, 0, 0, 2, '2024-12-20 09:30:00'),
(3, 1, 'ACM-ICPC区域赛总结', '参加了西安站的ACM区域赛虽然没能拿到金牌但收获很大。分享一下刷题心得和比赛策略特别是动态规划和图论算法的练习方法。建议大家多在Codeforces和AtCoder上练习这些平台的题目质量很高...', 189, 35, 6, 1, '2024-12-19 16:45:00'),
(3, 1, 'ACM-ICPC区域赛总结', '参加了西安站的ACM区域赛虽然没能拿到金牌但收获很大。分享一下刷题心得和比赛策略特别是动态规划和图论算法的练习方法。建议大家多在Codeforces和AtCoder上练习这些平台的题目质量很高...', 189, 0, 0, 1, '2024-12-19 16:45:00'),
(6, 1, '宏观经济学课程研讨:通胀与货币政策', '最近在学习宏观经济学,对当前的通胀形势和央行货币政策有一些思考。想和大家讨论一下利率调整对经济的影响机制,特别是在当前全球经济形势下的作用...', 145, 28, 4, 1, '2024-12-18 14:20:00'),
(6, 1, '宏观经济学课程研讨:通胀与货币政策', '最近在学习宏观经济学,对当前的通胀形势和央行货币政策有一些思考。想和大家讨论一下利率调整对经济的影响机制,特别是在当前全球经济形势下的作用...', 145, 0, 0, 1, '2024-12-18 14:20:00'),
-- 校园生活类帖子
(14, 2, '武大樱花季摄影大赛作品展示', '樱花季刚过分享一些在樱花大道拍摄的照片。今年的樱花开得特别美虽然人很多但还是拍到了一些不错的角度。附上拍摄技巧分享使用的是佳能5D4光圈f/2.8ISO400后期用LR调色...', 1234, 156, 12, 2, '2024-04-10 10:15:00'),
(14, 2, '武大樱花季摄影大赛作品展示', '樱花季刚过分享一些在樱花大道拍摄的照片。今年的樱花开得特别美虽然人很多但还是拍到了一些不错的角度。附上拍摄技巧分享使用的是佳能5D4光圈f/2.8ISO400后期用LR调色...', 1234, 0, 0, 2, '2024-04-10 10:15:00'),
(16, 2, '校运动会志愿者招募!', '第55届田径运动会即将开始现招募志愿者工作内容包括引导、记分、颁奖等。参与志愿服务可获得志愿时长认证还有纪念品哦有意向的同学请在评论区留言或私信联系我', 456, 89, 5, 1, '2024-12-15 08:00:00'),
(16, 2, '校运动会志愿者招募!', '第55届田径运动会即将开始现招募志愿者工作内容包括引导、记分、颁奖等。参与志愿服务可获得志愿时长认证还有纪念品哦有意向的同学请在评论区留言或私信联系我', 456, 0, 0, 1, '2024-12-15 08:00:00'),
(11, 2, '测绘学院野外实习日记', '刚从庐山实习回来分享一下野外测量的酸甜苦辣。早上5点起床背着仪器爬山虽然辛苦但收获满满。珞珈山的风景真是看不够啊学到了很多实际操作技能...', 234, 45, 7, 1, '2024-12-14 19:30:00'),
(11, 2, '测绘学院野外实习日记', '刚从庐山实习回来分享一下野外测量的酸甜苦辣。早上5点起床背着仪器爬山虽然辛苦但收获满满。珞珈山的风景真是看不够啊学到了很多实际操作技能...', 234, 0, 0, 1, '2024-12-14 19:30:00'),
-- 学院专区类帖子
(4, 6, '法学院模拟法庭大赛预告', '一年一度的"枫叶杯"模拟法庭大赛即将开始!欢迎各年级同学组队参加。比赛分为民事组和刑事组,优胜者将代表学院参加全国比赛。这是提升法律实务能力的绝佳机会...', 345, 67, 9, 2, '2024-12-16 11:00:00'),
(4, 6, '法学院模拟法庭大赛预告', '一年一度的"枫叶杯"模拟法庭大赛即将开始!欢迎各年级同学组队参加。比赛分为民事组和刑事组,优胜者将代表学院参加全国比赛。这是提升法律实务能力的绝佳机会...', 345, 0, 0, 2, '2024-12-16 11:00:00'),
(5, 6, '化学实验安全注意事项提醒', '最近实验室发生了几起小事故,提醒大家一定要注意安全!特别是使用强酸强碱时,护目镜和手套必须佩戴。实验无小事,安全第一!同时要做好实验记录...', 178, 34, 3, 1, '2024-12-17 15:20:00'),
(5, 6, '化学实验安全注意事项提醒', '最近实验室发生了几起小事故,提醒大家一定要注意安全!特别是使用强酸强碱时,护目镜和手套必须佩戴。实验无小事,安全第一!同时要做好实验记录...', 178, 0, 0, 1, '2024-12-17 15:20:00'),
-- 就业实习类帖子
(6, 3, '券商实习面试经验分享', '刚刚拿到某头部券商的实习offer分享一下面试经验。金融行业对专业能力和综合素质要求都很高准备过程中要注意这几个方面扎实的专业基础、良好的表达能力、对市场的敏感度...', 423, 78, 11, 1, '2024-12-21 14:00:00'),
(6, 3, '券商实习面试经验分享', '刚刚拿到某头部券商的实习offer分享一下面试经验。金融行业对专业能力和综合素质要求都很高准备过程中要注意这几个方面扎实的专业基础、良好的表达能力、对市场的敏感度...', 423, 0, 0, 1, '2024-12-21 14:00:00'),
(3, 3, 'IT互联网春招总结', '经历了春招季最终选择了某大厂的后端开发岗位。分享一下投递简历、技术面试、HR面试的全流程经验希望对计算机专业的同学有帮助。技术面试主要考察数据结构、算法、系统设计...', 567, 89, 15, 2, '2024-05-18 09:15:00'),
(3, 3, 'IT互联网春招总结', '经历了春招季最终选择了某大厂的后端开发岗位。分享一下投递简历、技术面试、HR面试的全流程经验希望对计算机专业的同学有帮助。技术面试主要考察数据结构、算法、系统设计...', 567, 0, 0, 2, '2024-05-18 09:15:00'),
-- 考研考公类帖子
(15, 7, '历史学考研经验贴', '成功上岸北师大中国史专业!分享一下备考经验:如何选择学校、如何制定复习计划、如何准备专业课等。考研路上不孤单,加油!专业课复习要注意史料分析和论述题...', 389, 72, 13, 1, '2024-12-10 22:00:00'),
(15, 7, '历史学考研经验贴', '成功上岸北师大中国史专业!分享一下备考经验:如何选择学校、如何制定复习计划、如何准备专业课等。考研路上不孤单,加油!专业课复习要注意史料分析和论述题...', 389, 0, 0, 1, '2024-12-10 22:00:00'),
-- 生活服务类帖子
(9, 8, '出售工科教材一批', '即将毕业,出售一些专业课教材:《结构力学》《材料力学》《工程制图》等,八成新,价格优惠。有需要的学弟学妹可以联系我~都是正版教材,保存得很好', 156, 12, 2, 1, '2024-12-22 18:30:00'),
(9, 8, '出售工科教材一批', '即将毕业,出售一些专业课教材:《结构力学》《材料力学》《工程制图》等,八成新,价格优惠。有需要的学弟学妹可以联系我~都是正版教材,保存得很好', 156, 0, 0, 1, '2024-12-22 18:30:00'),
(13, 8, '寻找珞珈山丢失的口腔器械包', '昨天在樱花大道丢失了一个蓝色器械包,里面有重要的口腔实习用具。如有好心人捡到,请联系我,必有重谢!器械包上有我的姓名标签', 89, 8, 1, 1, '2024-12-23 07:45:00');
(13, 8, '寻找珞珈山丢失的口腔器械包', '昨天在樱花大道丢失了一个蓝色器械包,里面有重要的口腔实习用具。如有好心人捡到,请联系我,必有重谢!器械包上有我的姓名标签', 89, 0, 0, 1, '2024-12-23 07:45:00');
-- 插入评论数据
INSERT INTO `comments` (`post_id`, `user_id`, `content`, `parent_id`, `like_count`, `status`, `created_at`) VALUES
@ -325,16 +325,77 @@ INSERT INTO `post_likes` (`user_id`, `post_id`, `created_at`) VALUES
(4, 9, '2024-12-21 15:00:00'),
(5, 2, '2024-12-19 17:30:00'),
(6, 1, '2024-12-20 13:00:00'),
(6, 10, '2024-05-18 10:00:00');
(6, 10, '2024-05-18 10:00:00'),
(7, 1, '2024-12-20 14:00:00'),
(8, 4, '2024-04-10 17:00:00'),
(9, 7, '2024-12-16 15:00:00'),
(10, 4, '2024-04-10 18:00:00'),
(11, 9, '2024-12-21 16:00:00'),
(12, 1, '2024-12-20 15:00:00'),
(13, 4, '2024-04-10 19:00:00'),
(14, 7, '2024-12-16 16:00:00'),
(15, 9, '2024-12-21 17:00:00'),
(16, 4, '2024-04-10 20:00:00'),
(2, 1, '2024-12-20 16:00:00'),
(3, 7, '2024-12-16 17:00:00'),
(5, 4, '2024-04-10 21:00:00'),
(6, 9, '2024-12-21 18:00:00'),
(8, 1, '2024-12-20 17:00:00'),
(9, 4, '2024-04-10 22:00:00'),
(11, 7, '2024-12-16 18:00:00'),
(13, 9, '2024-12-21 19:00:00'),
(14, 1, '2024-12-20 18:00:00'),
(15, 4, '2024-04-10 23:00:00'),
(16, 7, '2024-12-16 19:00:00');
-- 插入评论点赞数据
INSERT INTO `comment_likes` (`user_id`, `comment_id`, `created_at`) VALUES
-- 对评论的点赞
(2, 1, '2024-12-20 11:00:00'),
(3, 1, '2024-12-20 11:15:00'),
(4, 1, '2024-12-20 11:30:00'),
(5, 1, '2024-12-20 11:45:00'),
(6, 1, '2024-12-20 12:00:00'),
(7, 2, '2024-12-20 12:15:00'),
(8, 2, '2024-12-20 12:30:00'),
(9, 2, '2024-12-20 12:45:00'),
(10, 3, '2024-12-20 13:00:00'),
(11, 3, '2024-12-20 13:15:00'),
(12, 4, '2024-04-10 15:00:00'),
(13, 4, '2024-04-10 15:15:00'),
(14, 4, '2024-04-10 15:30:00'),
(15, 4, '2024-04-10 15:45:00'),
(16, 4, '2024-04-10 16:00:00'),
(2, 4, '2024-04-10 16:15:00'),
(3, 4, '2024-04-10 16:30:00'),
(5, 4, '2024-04-10 16:45:00'),
(6, 5, '2024-04-10 17:00:00'),
(7, 5, '2024-04-10 17:15:00'),
(8, 5, '2024-04-10 17:30:00'),
(9, 5, '2024-04-10 17:45:00'),
(10, 6, '2024-12-16 14:00:00'),
(11, 6, '2024-12-16 14:15:00'),
(12, 6, '2024-12-16 14:30:00'),
(13, 7, '2024-12-16 15:00:00'),
(14, 8, '2024-12-21 16:00:00'),
(15, 8, '2024-12-21 16:15:00'),
(16, 8, '2024-12-21 16:30:00'),
(2, 8, '2024-12-21 16:45:00'),
(3, 9, '2024-12-21 17:00:00'),
(4, 9, '2024-12-21 17:15:00'),
(5, 9, '2024-12-21 17:30:00'),
(6, 9, '2024-12-21 17:45:00'),
(7, 9, '2024-12-21 18:00:00'),
(8, 9, '2024-12-21 18:15:00');
-- 插入学习资源数据
INSERT INTO `resources` (`user_id`, `title`, `description`, `file_url`, `file_size`, `file_type`, `category_id`, `download_count`, `like_count`, `status`) VALUES
(2, '数据结构课程设计报告', '包含完整的数据结构课程设计实验报告,涵盖栈、队列、树、图等数据结构的实现和应用。', '/files/data-structure-report.pdf', 2048576, 'application/pdf', 1, 15, 8, 1),
(3, '算法导论学习笔记', '详细的算法导论学习笔记,包含排序算法、图算法、动态规划等重要算法的分析和实现。', '/files/algorithm-notes.docx', 1572864, 'application/msword', 1, 25, 12, 1),
(2, '高等数学期末复习资料', '高等数学期末考试复习资料合集,包含重要公式、定理证明和典型习题解答。', '/files/calculus-review.pdf', 3145728, 'application/pdf', 1, 32, 18, 1),
(6, '宏观经济学PPT课件', '经济学专业课件,包含货币政策、财政政策等核心内容。', '/files/macro-economics.pptx', 5242880, 'application/vnd.ms-powerpoint', 1, 20, 15, 1),
(14, '校园生活指南', '新生校园生活指南,包含宿舍管理、食堂介绍、图书馆使用等实用信息。', '/files/campus-guide.pdf', 1048576, 'application/pdf', 2, 45, 28, 1),
(3, '计算机网络实验代码', '计算机网络课程实验代码合集包含Socket编程、HTTP协议实现等。', '/files/network-lab-code.zip', 4194304, 'application/zip', 5, 18, 10, 1);
(2, '数据结构课程设计报告', '包含完整的数据结构课程设计实验报告,涵盖栈、队列、树、图等数据结构的实现和应用。', '/files/data-structure-report.pdf', 2048576, 'application/pdf', 1, 15, 0, 1),
(3, '算法导论学习笔记', '详细的算法导论学习笔记,包含排序算法、图算法、动态规划等重要算法的分析和实现。', '/files/algorithm-notes.docx', 1572864, 'application/msword', 1, 25, 0, 1),
(2, '高等数学期末复习资料', '高等数学期末考试复习资料合集,包含重要公式、定理证明和典型习题解答。', '/files/calculus-review.pdf', 3145728, 'application/pdf', 1, 32, 0, 1),
(6, '宏观经济学PPT课件', '经济学专业课件,包含货币政策、财政政策等核心内容。', '/files/macro-economics.pptx', 5242880, 'application/vnd.ms-powerpoint', 1, 20, 0, 1),
(14, '校园生活指南', '新生校园生活指南,包含宿舍管理、食堂介绍、图书馆使用等实用信息。', '/files/campus-guide.pdf', 1048576, 'application/pdf', 2, 45, 0, 1),
(3, '计算机网络实验代码', '计算机网络课程实验代码合集包含Socket编程、HTTP协议实现等。', '/files/network-lab-code.zip', 4194304, 'application/zip', 5, 18, 0, 1);
-- 插入课程数据
INSERT INTO `courses` (`user_id`, `name`, `teacher`, `location`, `day_of_week`, `start_time`, `end_time`, `start_week`, `end_week`, `semester`, `color`, `status`) VALUES
@ -376,11 +437,48 @@ INSERT INTO `schedules` (`user_id`, `title`, `description`, `start_time`, `end_t
(3, '项目答辩', '软件工程课程设计项目答辩', '2025-01-22 14:00:00', '2025-01-22 17:00:00', '信息学部B楼', 0, 720, '#E6A23C', 1),
(6, '考研复试准备', '准备经济学研究生复试材料', '2025-01-30 09:00:00', '2025-01-30 18:00:00', '图书馆', 1, 2880, '#9B59B6', 1);
-- ==========================================
-- 第五步:更新计数字段以保持数据一致性
-- ==========================================
-- 根据实际点赞数据更新帖子的点赞计数
UPDATE `posts` p SET
`like_count` = (
SELECT COUNT(*)
FROM `post_likes` pl
WHERE pl.`post_id` = p.`id`
);
-- 根据实际评论数据更新帖子的评论计数
UPDATE `posts` p SET
`comment_count` = (
SELECT COUNT(*)
FROM `comments` c
WHERE c.`post_id` = p.`id` AND c.`status` = 1
);
-- 根据实际点赞数据更新评论的点赞计数
UPDATE `comments` c SET
`like_count` = (
SELECT COUNT(*)
FROM `comment_likes` cl
WHERE cl.`comment_id` = c.`id`
);
-- 根据实际点赞数据更新资源的点赞计数
UPDATE `resources` r SET
`like_count` = (
SELECT COUNT(*)
FROM `resource_likes` rl
WHERE rl.`resource_id` = r.`id`
);
-- ==========================================
-- 完成提示
-- ==========================================
SELECT 'UniLife数据库重建完成所有表和测试数据已成功插入。' AS result;
SELECT '数据库结构:无外键约束,使用应用层维护数据一致性。' AS architecture;
SELECT '计数字段已根据实际数据自动更新,确保数据一致性。' AS consistency_check;
SELECT CONCAT('总共创建了 ', COUNT(*), ' 个表') AS table_count FROM information_schema.tables WHERE table_schema = 'UniLife';
SELECT '可以开始启动应用服务了!' AS next_step;

@ -0,0 +1,80 @@
package com.unilife.config;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;
/**
*
* Bean
*/
@TestConfiguration
public class TestConfig {
/**
* Redis
*/
@Bean
@Primary
public RedisConnectionFactory testRedisConnectionFactory() {
LettuceConnectionFactory factory = new LettuceConnectionFactory("localhost", 6379);
factory.setDatabase(1); // 使用数据库1进行测试
return factory;
}
/**
* RedisTemplate
*/
@Bean
@Primary
public RedisTemplate<String, Object> testRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
return template;
}
/**
* StringRedisTemplate
*/
@Bean
@Primary
public StringRedisTemplate testStringRedisTemplate(RedisConnectionFactory connectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(connectionFactory);
return template;
}
/**
*
*/
@Bean
@Primary
public JavaMailSender testJavaMailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost("smtp.example.com");
mailSender.setPort(587);
mailSender.setUsername("test@example.com");
mailSender.setPassword("testpassword");
Properties props = mailSender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.debug", "false"); // 测试时关闭debug
return mailSender;
}
}

@ -0,0 +1,169 @@
package com.unilife.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unilife.common.result.Result;
import com.unilife.model.dto.CreatePostDTO;
import com.unilife.model.dto.UpdatePostDTO;
import com.unilife.service.PostService;
import com.unilife.utils.BaseContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(PostController.class)
class PostControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PostService postService;
@Autowired
private ObjectMapper objectMapper;
private CreatePostDTO createPostDTO;
private UpdatePostDTO updatePostDTO;
@BeforeEach
void setUp() {
createPostDTO = new CreatePostDTO();
createPostDTO.setTitle("测试帖子");
createPostDTO.setContent("测试内容");
createPostDTO.setCategoryId(1L);
updatePostDTO = new UpdatePostDTO();
updatePostDTO.setTitle("更新标题");
updatePostDTO.setContent("更新内容");
updatePostDTO.setCategoryId(1L);
}
@Test
void testCreatePost_Success() throws Exception {
// Mock用户已登录
try (var mockedStatic = mockStatic(BaseContext.class)) {
mockedStatic.when(BaseContext::getId).thenReturn(1L);
when(postService.createPost(eq(1L), any(CreatePostDTO.class)))
.thenReturn(Result.success("帖子发布成功"));
mockMvc.perform(post("/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createPostDTO)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("帖子发布成功"));
verify(postService).createPost(eq(1L), any(CreatePostDTO.class));
}
}
@Test
void testCreatePost_Unauthorized() throws Exception {
// Mock用户未登录
try (var mockedStatic = mockStatic(BaseContext.class)) {
mockedStatic.when(BaseContext::getId).thenReturn(null);
mockMvc.perform(post("/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createPostDTO)))
.andExpect(status().isOk())
.andExpected(jsonPath("$.success").value(false))
.andExpected(jsonPath("$.code").value(401))
.andExpected(jsonPath("$.message").value("未登录"));
verify(postService, never()).createPost(anyLong(), any(CreatePostDTO.class));
}
}
@Test
void testGetPostDetail_Success() throws Exception {
when(postService.getPostDetail(eq(1L), any()))
.thenReturn(Result.success("帖子详情"));
mockMvc.perform(get("/posts/1"))
.andExpect(status().isOk())
.andExpected(jsonPath("$.success").value(true));
verify(postService).getPostDetail(eq(1L), any());
}
@Test
void testGetPostList_Success() throws Exception {
when(postService.getPostList(any(), any(), anyInt(), anyInt(), any(), any()))
.thenReturn(Result.success("帖子列表"));
mockMvc.perform(get("/posts")
.param("categoryId", "1")
.param("keyword", "测试")
.param("page", "1")
.param("size", "10")
.param("sort", "latest"))
.andExpected(status().isOk())
.andExpected(jsonPath("$.success").value(true));
verify(postService).getPostList(eq(1L), eq("测试"), eq(1), eq(10), eq("latest"), any());
}
@Test
void testUpdatePost_Success() throws Exception {
try (var mockedStatic = mockStatic(BaseContext.class)) {
mockedStatic.when(BaseContext::getId).thenReturn(1L);
when(postService.updatePost(eq(1L), eq(1L), any(UpdatePostDTO.class)))
.thenReturn(Result.success("帖子更新成功"));
mockMvc.perform(put("/posts/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updatePostDTO)))
.andExpected(status().isOk())
.andExpected(jsonPath("$.success").value(true))
.andExpected(jsonPath("$.message").value("帖子更新成功"));
verify(postService).updatePost(eq(1L), eq(1L), any(UpdatePostDTO.class));
}
}
@Test
void testDeletePost_Success() throws Exception {
try (var mockedStatic = mockStatic(BaseContext.class)) {
mockedStatic.when(BaseContext::getId).thenReturn(1L);
when(postService.deletePost(eq(1L), eq(1L)))
.thenReturn(Result.success("帖子删除成功"));
mockMvc.perform(delete("/posts/1"))
.andExpected(status().isOk())
.andExpected(jsonPath("$.success").value(true))
.andExpected(jsonPath("$.message").value("帖子删除成功"));
verify(postService).deletePost(eq(1L), eq(1L));
}
}
@Test
void testLikePost_Success() throws Exception {
try (var mockedStatic = mockStatic(BaseContext.class)) {
mockedStatic.when(BaseContext::getId).thenReturn(1L);
when(postService.likePost(eq(1L), eq(1L)))
.thenReturn(Result.success("点赞成功"));
mockMvc.perform(post("/posts/1/like"))
.andExpected(status().isOk())
.andExpected(jsonPath("$.success").value(true))
.andExpected(jsonPath("$.message").value("点赞成功"));
verify(postService).likePost(eq(1L), eq(1L));
}
}
}

@ -0,0 +1,287 @@
package com.unilife.service;
import com.unilife.common.result.Result;
import com.unilife.mapper.PostMapper;
import com.unilife.mapper.UserMapper;
import com.unilife.mapper.CategoryMapper;
import com.unilife.model.dto.CreatePostDTO;
import com.unilife.model.dto.UpdatePostDTO;
import com.unilife.model.entity.Post;
import com.unilife.model.entity.User;
import com.unilife.model.entity.Category;
import com.unilife.service.impl.PostServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@SpringBootTest
class PostServiceTest {
@Mock
private PostMapper postMapper;
@Mock
private UserMapper userMapper;
@Mock
private CategoryMapper categoryMapper;
@InjectMocks
private PostServiceImpl postService;
private User testUser;
private Category testCategory;
private Post testPost;
private CreatePostDTO createPostDTO;
private UpdatePostDTO updatePostDTO;
@BeforeEach
void setUp() {
// 初始化测试数据
testUser = new User();
testUser.setId(1L);
testUser.setNickname("测试用户");
testUser.setAvatar("avatar.jpg");
testCategory = new Category();
testCategory.setId(1L);
testCategory.setName("学习讨论");
testCategory.setStatus(1);
testPost = new Post();
testPost.setId(1L);
testPost.setTitle("测试帖子");
testPost.setContent("这是一个测试帖子的内容");
testPost.setUserId(1L);
testPost.setCategoryId(1L);
testPost.setLikeCount(0);
testPost.setViewCount(0);
testPost.setCommentCount(0);
testPost.setCreatedAt(LocalDateTime.now());
testPost.setUpdatedAt(LocalDateTime.now());
createPostDTO = new CreatePostDTO();
createPostDTO.setTitle("新帖子标题");
createPostDTO.setContent("新帖子内容");
createPostDTO.setCategoryId(1L);
updatePostDTO = new UpdatePostDTO();
updatePostDTO.setTitle("更新后的标题");
updatePostDTO.setContent("更新后的内容");
updatePostDTO.setCategoryId(1L);
}
@Test
void testCreatePost_Success() {
// Mock 依赖方法
when(userMapper.findById(1L)).thenReturn(testUser);
when(categoryMapper.findById(1L)).thenReturn(testCategory);
when(postMapper.insert(any(Post.class))).thenReturn(1);
// 执行测试
Result<?> result = postService.createPost(1L, createPostDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("帖子发布成功", result.getMessage());
// 验证方法调用
verify(userMapper).findById(1L);
verify(categoryMapper).findById(1L);
verify(postMapper).insert(any(Post.class));
}
@Test
void testCreatePost_UserNotFound() {
// Mock 用户不存在
when(userMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = postService.createPost(1L, createPostDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("用户不存在", result.getMessage());
// 验证不会尝试创建帖子
verify(postMapper, never()).insert(any(Post.class));
}
@Test
void testCreatePost_CategoryNotFound() {
// Mock 用户存在但分类不存在
when(userMapper.findById(1L)).thenReturn(testUser);
when(categoryMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = postService.createPost(1L, createPostDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("分类不存在", result.getMessage());
}
@Test
void testCreatePost_InvalidTitle() {
// 测试空标题
createPostDTO.setTitle("");
// 执行测试
Result<?> result = postService.createPost(1L, createPostDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertTrue(result.getMessage().contains("标题不能为空"));
}
@Test
void testGetPostDetail_Success() {
// Mock 依赖方法
when(postMapper.findById(1L)).thenReturn(testPost);
when(userMapper.findById(1L)).thenReturn(testUser);
when(categoryMapper.findById(1L)).thenReturn(testCategory);
// 执行测试
Result<?> result = postService.getPostDetail(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
// 验证浏览量增加
verify(postMapper).updateViewCount(1L);
}
@Test
void testGetPostDetail_PostNotFound() {
// Mock 帖子不存在
when(postMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = postService.getPostDetail(1L, 1L);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("帖子不存在", result.getMessage());
}
@Test
void testGetPostList_Success() {
// Mock 帖子列表
List<Post> posts = Arrays.asList(testPost);
when(postMapper.findByConditions(any(), any(), anyInt(), anyInt(), any())).thenReturn(posts);
when(postMapper.countByConditions(any(), any())).thenReturn(1);
// 执行测试
Result<?> result = postService.getPostList(1L, "测试", 1, 10, "latest", 1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
}
@Test
void testUpdatePost_Success() {
// Mock 依赖方法
when(postMapper.findById(1L)).thenReturn(testPost);
when(categoryMapper.findById(1L)).thenReturn(testCategory);
when(postMapper.update(any(Post.class))).thenReturn(1);
// 执行测试
Result<?> result = postService.updatePost(1L, 1L, updatePostDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("帖子更新成功", result.getMessage());
// 验证方法调用
verify(postMapper).update(any(Post.class));
}
@Test
void testUpdatePost_Unauthorized() {
// Mock 其他用户的帖子
testPost.setUserId(2L);
when(postMapper.findById(1L)).thenReturn(testPost);
// 执行测试
Result<?> result = postService.updatePost(1L, 1L, updatePostDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(403, result.getCode());
assertEquals("无权限修改此帖子", result.getMessage());
}
@Test
void testDeletePost_Success() {
// Mock 依赖方法
when(postMapper.findById(1L)).thenReturn(testPost);
when(postMapper.delete(1L)).thenReturn(1);
// 执行测试
Result<?> result = postService.deletePost(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("帖子删除成功", result.getMessage());
// 验证方法调用
verify(postMapper).delete(1L);
}
@Test
void testLikePost_Success() {
// Mock 依赖方法
when(postMapper.findById(1L)).thenReturn(testPost);
when(postMapper.isLikedByUser(1L, 1L)).thenReturn(false);
when(postMapper.insertLike(1L, 1L)).thenReturn(1);
// 执行测试
Result<?> result = postService.likePost(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("点赞成功", result.getMessage());
// 验证方法调用
verify(postMapper).insertLike(1L, 1L);
verify(postMapper).updateLikeCount(1L, 1);
}
@Test
void testUnlikePost_Success() {
// Mock 已点赞状态
when(postMapper.findById(1L)).thenReturn(testPost);
when(postMapper.isLikedByUser(1L, 1L)).thenReturn(true);
when(postMapper.deleteLike(1L, 1L)).thenReturn(1);
// 执行测试
Result<?> result = postService.likePost(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("取消点赞成功", result.getMessage());
// 验证方法调用
verify(postMapper).deleteLike(1L, 1L);
verify(postMapper).updateLikeCount(1L, -1);
}
}

@ -0,0 +1,348 @@
package com.unilife.service;
import com.unilife.common.result.Result;
import com.unilife.mapper.ResourceMapper;
import com.unilife.mapper.UserMapper;
import com.unilife.mapper.CategoryMapper;
import com.unilife.model.dto.CreateResourceDTO;
import com.unilife.model.entity.Resource;
import com.unilife.model.entity.User;
import com.unilife.model.entity.Category;
import com.unilife.service.impl.ResourceServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@SpringBootTest
class ResourceServiceTest {
@Mock
private ResourceMapper resourceMapper;
@Mock
private UserMapper userMapper;
@Mock
private CategoryMapper categoryMapper;
@InjectMocks
private ResourceServiceImpl resourceService;
private User testUser;
private Category testCategory;
private Resource testResource;
private CreateResourceDTO createResourceDTO;
private MockMultipartFile mockFile;
@BeforeEach
void setUp() {
// 初始化测试数据
testUser = new User();
testUser.setId(1L);
testUser.setNickname("测试用户");
testUser.setAvatar("avatar.jpg");
testCategory = new Category();
testCategory.setId(1L);
testCategory.setName("学习资料");
testCategory.setStatus(1);
testResource = new Resource();
testResource.setId(1L);
testResource.setTitle("测试资源");
testResource.setDescription("测试资源描述");
testResource.setFileName("test.pdf");
testResource.setFileUrl("http://example.com/test.pdf");
testResource.setFileSize(1024L);
testResource.setFileType("pdf");
testResource.setUserId(1L);
testResource.setCategoryId(1L);
testResource.setDownloadCount(0);
testResource.setLikeCount(0);
testResource.setCreatedAt(LocalDateTime.now());
testResource.setUpdatedAt(LocalDateTime.now());
createResourceDTO = new CreateResourceDTO();
createResourceDTO.setTitle("新资源标题");
createResourceDTO.setDescription("新资源描述");
createResourceDTO.setCategoryId(1L);
mockFile = new MockMultipartFile(
"file",
"test.pdf",
"application/pdf",
"test content".getBytes()
);
}
@Test
void testUploadResource_Success() {
// Mock 依赖方法
when(userMapper.findById(1L)).thenReturn(testUser);
when(categoryMapper.findById(1L)).thenReturn(testCategory);
when(resourceMapper.insert(any(Resource.class))).thenReturn(1);
// 执行测试
Result<?> result = resourceService.uploadResource(1L, createResourceDTO, mockFile);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("资源上传成功", result.getMessage());
// 验证方法调用
verify(userMapper).findById(1L);
verify(categoryMapper).findById(1L);
verify(resourceMapper).insert(any(Resource.class));
}
@Test
void testUploadResource_UserNotFound() {
// Mock 用户不存在
when(userMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = resourceService.uploadResource(1L, createResourceDTO, mockFile);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("用户不存在", result.getMessage());
// 验证不会尝试上传资源
verify(resourceMapper, never()).insert(any(Resource.class));
}
@Test
void testUploadResource_CategoryNotFound() {
// Mock 用户存在但分类不存在
when(userMapper.findById(1L)).thenReturn(testUser);
when(categoryMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = resourceService.uploadResource(1L, createResourceDTO, mockFile);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("分类不存在", result.getMessage());
}
@Test
void testUploadResource_EmptyFile() {
// 测试空文件
MockMultipartFile emptyFile = new MockMultipartFile(
"file",
"empty.pdf",
"application/pdf",
new byte[0]
);
// 执行测试
Result<?> result = resourceService.uploadResource(1L, createResourceDTO, emptyFile);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertEquals("文件不能为空", result.getMessage());
}
@Test
void testUploadResource_InvalidFileType() {
// 测试不支持的文件类型
MockMultipartFile invalidFile = new MockMultipartFile(
"file",
"test.exe",
"application/octet-stream",
"test content".getBytes()
);
// 执行测试
Result<?> result = resourceService.uploadResource(1L, createResourceDTO, invalidFile);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertTrue(result.getMessage().contains("不支持的文件类型"));
}
@Test
void testGetResourceDetail_Success() {
// Mock 依赖方法
when(resourceMapper.findById(1L)).thenReturn(testResource);
when(userMapper.findById(1L)).thenReturn(testUser);
when(categoryMapper.findById(1L)).thenReturn(testCategory);
// 执行测试
Result<?> result = resourceService.getResourceDetail(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
}
@Test
void testGetResourceDetail_ResourceNotFound() {
// Mock 资源不存在
when(resourceMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = resourceService.getResourceDetail(1L, 1L);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("资源不存在", result.getMessage());
}
@Test
void testGetResourceList_Success() {
// Mock 资源列表
List<Resource> resources = Arrays.asList(testResource);
when(resourceMapper.findByConditions(any(), any(), any(), anyInt(), anyInt())).thenReturn(resources);
when(resourceMapper.countByConditions(any(), any(), any())).thenReturn(1);
// 执行测试
Result<?> result = resourceService.getResourceList(1L, 1L, "测试", 1, 10, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
}
@Test
void testUpdateResource_Success() {
// Mock 依赖方法
when(resourceMapper.findById(1L)).thenReturn(testResource);
when(categoryMapper.findById(1L)).thenReturn(testCategory);
when(resourceMapper.update(any(Resource.class))).thenReturn(1);
// 执行测试
Result<?> result = resourceService.updateResource(1L, 1L, createResourceDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("资源更新成功", result.getMessage());
// 验证方法调用
verify(resourceMapper).update(any(Resource.class));
}
@Test
void testUpdateResource_Unauthorized() {
// Mock 其他用户的资源
testResource.setUserId(2L);
when(resourceMapper.findById(1L)).thenReturn(testResource);
// 执行测试
Result<?> result = resourceService.updateResource(1L, 1L, createResourceDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(403, result.getCode());
assertEquals("无权限修改此资源", result.getMessage());
}
@Test
void testDeleteResource_Success() {
// Mock 依赖方法
when(resourceMapper.findById(1L)).thenReturn(testResource);
when(resourceMapper.delete(1L)).thenReturn(1);
// 执行测试
Result<?> result = resourceService.deleteResource(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("资源删除成功", result.getMessage());
// 验证方法调用
verify(resourceMapper).delete(1L);
}
@Test
void testDownloadResource_Success() {
// Mock 依赖方法
when(resourceMapper.findById(1L)).thenReturn(testResource);
// 执行测试
Result<?> result = resourceService.downloadResource(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
// 验证下载量增加
verify(resourceMapper).updateDownloadCount(1L);
}
@Test
void testLikeResource_Success() {
// Mock 依赖方法
when(resourceMapper.findById(1L)).thenReturn(testResource);
when(resourceMapper.isLikedByUser(1L, 1L)).thenReturn(false);
when(resourceMapper.insertLike(1L, 1L)).thenReturn(1);
// 执行测试
Result<?> result = resourceService.likeResource(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("点赞成功", result.getMessage());
// 验证方法调用
verify(resourceMapper).insertLike(1L, 1L);
verify(resourceMapper).updateLikeCount(1L, 1);
}
@Test
void testUnlikeResource_Success() {
// Mock 已点赞状态
when(resourceMapper.findById(1L)).thenReturn(testResource);
when(resourceMapper.isLikedByUser(1L, 1L)).thenReturn(true);
when(resourceMapper.deleteLike(1L, 1L)).thenReturn(1);
// 执行测试
Result<?> result = resourceService.likeResource(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("取消点赞成功", result.getMessage());
// 验证方法调用
verify(resourceMapper).deleteLike(1L, 1L);
verify(resourceMapper).updateLikeCount(1L, -1);
}
@Test
void testGetUserResources_Success() {
// Mock 用户资源列表
List<Resource> userResources = Arrays.asList(testResource);
when(resourceMapper.findByUserId(eq(1L), anyInt(), anyInt())).thenReturn(userResources);
when(resourceMapper.countByUserId(1L)).thenReturn(1);
// 执行测试
Result<?> result = resourceService.getUserResources(1L, 1, 10);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
// 验证方法调用
verify(resourceMapper).findByUserId(eq(1L), anyInt(), anyInt());
verify(resourceMapper).countByUserId(1L);
}
}

@ -0,0 +1,370 @@
package com.unilife.service;
import com.unilife.common.result.Result;
import com.unilife.mapper.ScheduleMapper;
import com.unilife.mapper.UserMapper;
import com.unilife.model.dto.CreateScheduleDTO;
import com.unilife.model.entity.Schedule;
import com.unilife.model.entity.User;
import com.unilife.service.impl.ScheduleServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@SpringBootTest
class ScheduleServiceTest {
@Mock
private ScheduleMapper scheduleMapper;
@Mock
private UserMapper userMapper;
@InjectMocks
private ScheduleServiceImpl scheduleService;
private User testUser;
private Schedule testSchedule;
private CreateScheduleDTO createScheduleDTO;
@BeforeEach
void setUp() {
// 初始化测试数据
testUser = new User();
testUser.setId(1L);
testUser.setNickname("测试用户");
testUser.setAvatar("avatar.jpg");
testSchedule = new Schedule();
testSchedule.setId(1L);
testSchedule.setTitle("测试课程");
testSchedule.setDescription("测试课程描述");
testSchedule.setStartTime(LocalDateTime.of(2024, 1, 15, 9, 0));
testSchedule.setEndTime(LocalDateTime.of(2024, 1, 15, 10, 30));
testSchedule.setLocation("教学楼A101");
testSchedule.setType("COURSE");
testSchedule.setRepeatType("WEEKLY");
testSchedule.setRepeatEnd(LocalDateTime.of(2024, 6, 15, 10, 30));
testSchedule.setUserId(1L);
testSchedule.setCreatedAt(LocalDateTime.now());
testSchedule.setUpdatedAt(LocalDateTime.now());
createScheduleDTO = new CreateScheduleDTO();
createScheduleDTO.setTitle("新课程");
createScheduleDTO.setDescription("新课程描述");
createScheduleDTO.setStartTime(LocalDateTime.of(2024, 1, 16, 14, 0));
createScheduleDTO.setEndTime(LocalDateTime.of(2024, 1, 16, 15, 30));
createScheduleDTO.setLocation("教学楼B201");
createScheduleDTO.setType("COURSE");
createScheduleDTO.setRepeatType("WEEKLY");
createScheduleDTO.setRepeatEnd(LocalDateTime.of(2024, 6, 16, 15, 30));
}
@Test
void testCreateSchedule_Success() {
// Mock 依赖方法
when(userMapper.findById(1L)).thenReturn(testUser);
when(scheduleMapper.findConflictingSchedules(eq(1L), any(), any(), any())).thenReturn(Arrays.asList());
when(scheduleMapper.insert(any(Schedule.class))).thenReturn(1);
// 执行测试
Result<?> result = scheduleService.createSchedule(1L, createScheduleDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("日程创建成功", result.getMessage());
// 验证方法调用
verify(userMapper).findById(1L);
verify(scheduleMapper).findConflictingSchedules(eq(1L), any(), any(), any());
verify(scheduleMapper).insert(any(Schedule.class));
}
@Test
void testCreateSchedule_UserNotFound() {
// Mock 用户不存在
when(userMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = scheduleService.createSchedule(1L, createScheduleDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("用户不存在", result.getMessage());
// 验证不会尝试创建日程
verify(scheduleMapper, never()).insert(any(Schedule.class));
}
@Test
void testCreateSchedule_TimeConflict() {
// Mock 时间冲突
Schedule conflictingSchedule = new Schedule();
conflictingSchedule.setId(2L);
conflictingSchedule.setTitle("冲突课程");
conflictingSchedule.setStartTime(LocalDateTime.of(2024, 1, 16, 14, 30));
conflictingSchedule.setEndTime(LocalDateTime.of(2024, 1, 16, 16, 0));
when(userMapper.findById(1L)).thenReturn(testUser);
when(scheduleMapper.findConflictingSchedules(eq(1L), any(), any(), any()))
.thenReturn(Arrays.asList(conflictingSchedule));
// 执行测试
Result<?> result = scheduleService.createSchedule(1L, createScheduleDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertTrue(result.getMessage().contains("时间冲突"));
}
@Test
void testCreateSchedule_InvalidTimeRange() {
// 测试结束时间早于开始时间
createScheduleDTO.setStartTime(LocalDateTime.of(2024, 1, 16, 16, 0));
createScheduleDTO.setEndTime(LocalDateTime.of(2024, 1, 16, 14, 0));
// 执行测试
Result<?> result = scheduleService.createSchedule(1L, createScheduleDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertEquals("结束时间不能早于开始时间", result.getMessage());
}
@Test
void testGetScheduleDetail_Success() {
// Mock 依赖方法
when(scheduleMapper.findById(1L)).thenReturn(testSchedule);
// 执行测试
Result<?> result = scheduleService.getScheduleDetail(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
}
@Test
void testGetScheduleDetail_NotFound() {
// Mock 日程不存在
when(scheduleMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = scheduleService.getScheduleDetail(1L, 1L);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("日程不存在", result.getMessage());
}
@Test
void testGetScheduleDetail_Unauthorized() {
// Mock 其他用户的日程
testSchedule.setUserId(2L);
when(scheduleMapper.findById(1L)).thenReturn(testSchedule);
// 执行测试
Result<?> result = scheduleService.getScheduleDetail(1L, 1L);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(403, result.getCode());
assertEquals("无权限查看此日程", result.getMessage());
}
@Test
void testGetScheduleList_Success() {
// Mock 日程列表
List<Schedule> schedules = Arrays.asList(testSchedule);
when(scheduleMapper.findByUserId(1L)).thenReturn(schedules);
// 执行测试
Result<?> result = scheduleService.getScheduleList(1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
// 验证方法调用
verify(scheduleMapper).findByUserId(1L);
}
@Test
void testGetScheduleListByTimeRange_Success() {
LocalDateTime startTime = LocalDateTime.of(2024, 1, 1, 0, 0);
LocalDateTime endTime = LocalDateTime.of(2024, 1, 31, 23, 59);
// Mock 时间范围内的日程列表
List<Schedule> schedules = Arrays.asList(testSchedule);
when(scheduleMapper.findByUserIdAndTimeRange(1L, startTime, endTime)).thenReturn(schedules);
// 执行测试
Result<?> result = scheduleService.getScheduleListByTimeRange(1L, startTime, endTime);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
// 验证方法调用
verify(scheduleMapper).findByUserIdAndTimeRange(1L, startTime, endTime);
}
@Test
void testUpdateSchedule_Success() {
// Mock 依赖方法
when(scheduleMapper.findById(1L)).thenReturn(testSchedule);
when(scheduleMapper.findConflictingSchedules(eq(1L), any(), any(), eq(1L))).thenReturn(Arrays.asList());
when(scheduleMapper.update(any(Schedule.class))).thenReturn(1);
// 执行测试
Result<?> result = scheduleService.updateSchedule(1L, 1L, createScheduleDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("日程更新成功", result.getMessage());
// 验证方法调用
verify(scheduleMapper).update(any(Schedule.class));
}
@Test
void testUpdateSchedule_Unauthorized() {
// Mock 其他用户的日程
testSchedule.setUserId(2L);
when(scheduleMapper.findById(1L)).thenReturn(testSchedule);
// 执行测试
Result<?> result = scheduleService.updateSchedule(1L, 1L, createScheduleDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(403, result.getCode());
assertEquals("无权限修改此日程", result.getMessage());
}
@Test
void testDeleteSchedule_Success() {
// Mock 依赖方法
when(scheduleMapper.findById(1L)).thenReturn(testSchedule);
when(scheduleMapper.delete(1L)).thenReturn(1);
// 执行测试
Result<?> result = scheduleService.deleteSchedule(1L, 1L);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("日程删除成功", result.getMessage());
// 验证方法调用
verify(scheduleMapper).delete(1L);
}
@Test
void testCheckScheduleConflict_NoConflict() {
LocalDateTime startTime = LocalDateTime.of(2024, 1, 16, 14, 0);
LocalDateTime endTime = LocalDateTime.of(2024, 1, 16, 15, 30);
// Mock 无冲突
when(scheduleMapper.findConflictingSchedules(eq(1L), eq(startTime), eq(endTime), any()))
.thenReturn(Arrays.asList());
// 执行测试
Result<?> result = scheduleService.checkScheduleConflict(1L, startTime, endTime, null);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("无时间冲突", result.getMessage());
}
@Test
void testCheckScheduleConflict_HasConflict() {
LocalDateTime startTime = LocalDateTime.of(2024, 1, 16, 14, 0);
LocalDateTime endTime = LocalDateTime.of(2024, 1, 16, 15, 30);
// Mock 有冲突
when(scheduleMapper.findConflictingSchedules(eq(1L), eq(startTime), eq(endTime), any()))
.thenReturn(Arrays.asList(testSchedule));
// 执行测试
Result<?> result = scheduleService.checkScheduleConflict(1L, startTime, endTime, null);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertTrue(result.getMessage().contains("时间冲突"));
}
@Test
void testProcessScheduleReminders_Success() {
// Mock 需要提醒的日程
List<Schedule> upcomingSchedules = Arrays.asList(testSchedule);
when(scheduleMapper.findUpcomingSchedules(any())).thenReturn(upcomingSchedules);
// 执行测试
Result<?> result = scheduleService.processScheduleReminders();
// 验证结果
assertTrue(result.isSuccess());
assertEquals("提醒处理完成", result.getMessage());
// 验证方法调用
verify(scheduleMapper).findUpcomingSchedules(any());
}
@Test
void testCreateSchedule_WeeklyRepeat() {
// 测试周重复日程
createScheduleDTO.setRepeatType("WEEKLY");
createScheduleDTO.setRepeatEnd(LocalDateTime.of(2024, 3, 16, 15, 30));
when(userMapper.findById(1L)).thenReturn(testUser);
when(scheduleMapper.findConflictingSchedules(eq(1L), any(), any(), any())).thenReturn(Arrays.asList());
when(scheduleMapper.insert(any(Schedule.class))).thenReturn(1);
// 执行测试
Result<?> result = scheduleService.createSchedule(1L, createScheduleDTO);
// 验证结果
assertTrue(result.isSuccess());
// 验证会创建多个重复的日程实例
verify(scheduleMapper, atLeast(1)).insert(any(Schedule.class));
}
@Test
void testCreateSchedule_DailyRepeat() {
// 测试日重复日程
createScheduleDTO.setRepeatType("DAILY");
createScheduleDTO.setRepeatEnd(LocalDateTime.of(2024, 1, 20, 15, 30));
when(userMapper.findById(1L)).thenReturn(testUser);
when(scheduleMapper.findConflictingSchedules(eq(1L), any(), any(), any())).thenReturn(Arrays.asList());
when(scheduleMapper.insert(any(Schedule.class))).thenReturn(1);
// 执行测试
Result<?> result = scheduleService.createSchedule(1L, createScheduleDTO);
// 验证结果
assertTrue(result.isSuccess());
// 验证会创建多个重复的日程实例
verify(scheduleMapper, atLeast(1)).insert(any(Schedule.class));
}
}

@ -0,0 +1,438 @@
package com.unilife.service;
import com.unilife.common.result.Result;
import com.unilife.mapper.UserMapper;
import com.unilife.model.dto.CreateUserDTO;
import com.unilife.model.dto.UpdateUserDTO;
import com.unilife.model.dto.LoginDTO;
import com.unilife.model.entity.User;
import com.unilife.service.impl.UserServiceImpl;
import com.unilife.utils.JwtUtil;
import com.unilife.utils.PasswordUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@SpringBootTest
class UserServiceTest {
@Mock
private UserMapper userMapper;
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private JavaMailSender mailSender;
@InjectMocks
private UserServiceImpl userService;
private User testUser;
private CreateUserDTO createUserDTO;
private UpdateUserDTO updateUserDTO;
private LoginDTO loginDTO;
@BeforeEach
void setUp() {
// 初始化测试数据
testUser = new User();
testUser.setId(1L);
testUser.setUsername("testuser");
testUser.setEmail("test@example.com");
testUser.setNickname("测试用户");
testUser.setPassword("$2a$10$encrypted_password"); // 模拟加密后的密码
testUser.setAvatar("avatar.jpg");
testUser.setStatus(1);
testUser.setCreatedAt(LocalDateTime.now());
testUser.setUpdatedAt(LocalDateTime.now());
createUserDTO = new CreateUserDTO();
createUserDTO.setUsername("newuser");
createUserDTO.setEmail("newuser@example.com");
createUserDTO.setNickname("新用户");
createUserDTO.setPassword("password123");
updateUserDTO = new UpdateUserDTO();
updateUserDTO.setNickname("更新后的昵称");
updateUserDTO.setAvatar("new_avatar.jpg");
loginDTO = new LoginDTO();
loginDTO.setUsername("testuser");
loginDTO.setPassword("password123");
}
@Test
void testRegister_Success() {
// Mock 依赖方法
when(userMapper.findByUsername("newuser")).thenReturn(null);
when(userMapper.findByEmail("newuser@example.com")).thenReturn(null);
when(userMapper.insert(any(User.class))).thenReturn(1);
try (MockedStatic<PasswordUtil> passwordUtil = mockStatic(PasswordUtil.class)) {
passwordUtil.when(() -> PasswordUtil.encode("password123"))
.thenReturn("$2a$10$encrypted_password");
// 执行测试
Result<?> result = userService.register(createUserDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("注册成功", result.getMessage());
// 验证方法调用
verify(userMapper).findByUsername("newuser");
verify(userMapper).findByEmail("newuser@example.com");
verify(userMapper).insert(any(User.class));
}
}
@Test
void testRegister_UsernameExists() {
// Mock 用户名已存在
when(userMapper.findByUsername("newuser")).thenReturn(testUser);
// 执行测试
Result<?> result = userService.register(createUserDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertEquals("用户名已存在", result.getMessage());
// 验证不会尝试插入用户
verify(userMapper, never()).insert(any(User.class));
}
@Test
void testRegister_EmailExists() {
// Mock 邮箱已存在
when(userMapper.findByUsername("newuser")).thenReturn(null);
when(userMapper.findByEmail("newuser@example.com")).thenReturn(testUser);
// 执行测试
Result<?> result = userService.register(createUserDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertEquals("邮箱已存在", result.getMessage());
}
@Test
void testLogin_Success() {
// Mock 依赖方法
when(userMapper.findByUsername("testuser")).thenReturn(testUser);
try (MockedStatic<PasswordUtil> passwordUtil = mockStatic(PasswordUtil.class);
MockedStatic<JwtUtil> jwtUtil = mockStatic(JwtUtil.class)) {
passwordUtil.when(() -> PasswordUtil.matches("password123", "$2a$10$encrypted_password"))
.thenReturn(true);
jwtUtil.when(() -> JwtUtil.generateToken(1L))
.thenReturn("mock_jwt_token");
// 执行测试
Result<?> result = userService.login(loginDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("登录成功", result.getMessage());
assertNotNull(result.getData());
// 验证方法调用
verify(userMapper).findByUsername("testuser");
verify(userMapper).updateLastLoginTime(1L);
}
}
@Test
void testLogin_UserNotFound() {
// Mock 用户不存在
when(userMapper.findByUsername("testuser")).thenReturn(null);
// 执行测试
Result<?> result = userService.login(loginDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(401, result.getCode());
assertEquals("用户名或密码错误", result.getMessage());
}
@Test
void testLogin_PasswordIncorrect() {
// Mock 密码错误
when(userMapper.findByUsername("testuser")).thenReturn(testUser);
try (MockedStatic<PasswordUtil> passwordUtil = mockStatic(PasswordUtil.class)) {
passwordUtil.when(() -> PasswordUtil.matches("password123", "$2a$10$encrypted_password"))
.thenReturn(false);
// 执行测试
Result<?> result = userService.login(loginDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(401, result.getCode());
assertEquals("用户名或密码错误", result.getMessage());
}
}
@Test
void testLogin_UserDisabled() {
// Mock 用户被禁用
testUser.setStatus(0);
when(userMapper.findByUsername("testuser")).thenReturn(testUser);
try (MockedStatic<PasswordUtil> passwordUtil = mockStatic(PasswordUtil.class)) {
passwordUtil.when(() -> PasswordUtil.matches("password123", "$2a$10$encrypted_password"))
.thenReturn(true);
// 执行测试
Result<?> result = userService.login(loginDTO);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(403, result.getCode());
assertEquals("账户已被禁用", result.getMessage());
}
}
@Test
void testGetUserInfo_Success() {
// Mock 依赖方法
when(userMapper.findById(1L)).thenReturn(testUser);
// 执行测试
Result<?> result = userService.getUserInfo(1L);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
// 验证方法调用
verify(userMapper).findById(1L);
}
@Test
void testGetUserInfo_UserNotFound() {
// Mock 用户不存在
when(userMapper.findById(1L)).thenReturn(null);
// 执行测试
Result<?> result = userService.getUserInfo(1L);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(404, result.getCode());
assertEquals("用户不存在", result.getMessage());
}
@Test
void testUpdateUserInfo_Success() {
// Mock 依赖方法
when(userMapper.findById(1L)).thenReturn(testUser);
when(userMapper.update(any(User.class))).thenReturn(1);
// 执行测试
Result<?> result = userService.updateUserInfo(1L, updateUserDTO);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("用户信息更新成功", result.getMessage());
// 验证方法调用
verify(userMapper).update(any(User.class));
}
@Test
void testSendEmailVerificationCode_Success() {
String email = "test@example.com";
String verificationCode = "123456";
// Mock Redis操作
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
// 执行测试
Result<?> result = userService.sendEmailVerificationCode(email);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("验证码发送成功", result.getMessage());
// 验证邮件发送
verify(mailSender).send(any(SimpleMailMessage.class));
}
@Test
void testVerifyEmailCode_Success() {
String email = "test@example.com";
String code = "123456";
// Mock Redis操作
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.opsForValue().get("email_code:" + email)).thenReturn(code);
// 执行测试
Result<?> result = userService.verifyEmailCode(email, code);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("验证码验证成功", result.getMessage());
// 验证删除验证码
verify(redisTemplate).delete("email_code:" + email);
}
@Test
void testVerifyEmailCode_CodeExpired() {
String email = "test@example.com";
String code = "123456";
// Mock 验证码不存在(已过期)
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.opsForValue().get("email_code:" + email)).thenReturn(null);
// 执行测试
Result<?> result = userService.verifyEmailCode(email, code);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertEquals("验证码已过期", result.getMessage());
}
@Test
void testVerifyEmailCode_CodeIncorrect() {
String email = "test@example.com";
String code = "123456";
String wrongCode = "654321";
// Mock 验证码错误
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.opsForValue().get("email_code:" + email)).thenReturn(wrongCode);
// 执行测试
Result<?> result = userService.verifyEmailCode(email, code);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertEquals("验证码错误", result.getMessage());
}
@Test
void testResetPassword_Success() {
String email = "test@example.com";
String newPassword = "newpassword123";
// Mock 依赖方法
when(userMapper.findByEmail(email)).thenReturn(testUser);
when(userMapper.updatePassword(eq(1L), anyString())).thenReturn(1);
try (MockedStatic<PasswordUtil> passwordUtil = mockStatic(PasswordUtil.class)) {
passwordUtil.when(() -> PasswordUtil.encode(newPassword))
.thenReturn("$2a$10$new_encrypted_password");
// 执行测试
Result<?> result = userService.resetPassword(email, newPassword);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("密码重置成功", result.getMessage());
// 验证方法调用
verify(userMapper).updatePassword(eq(1L), eq("$2a$10$new_encrypted_password"));
}
}
@Test
void testGetUserList_Success() {
// Mock 用户列表
List<User> users = Arrays.asList(testUser);
when(userMapper.findByConditions(any(), any(), anyInt(), anyInt())).thenReturn(users);
when(userMapper.countByConditions(any(), any())).thenReturn(1);
// 执行测试
Result<?> result = userService.getUserList("测试", 1, 1, 10);
// 验证结果
assertTrue(result.isSuccess());
assertNotNull(result.getData());
// 验证方法调用
verify(userMapper).findByConditions(any(), any(), anyInt(), anyInt());
verify(userMapper).countByConditions(any(), any());
}
@Test
void testChangePassword_Success() {
String oldPassword = "oldpassword";
String newPassword = "newpassword123";
// Mock 依赖方法
when(userMapper.findById(1L)).thenReturn(testUser);
when(userMapper.updatePassword(eq(1L), anyString())).thenReturn(1);
try (MockedStatic<PasswordUtil> passwordUtil = mockStatic(PasswordUtil.class)) {
passwordUtil.when(() -> PasswordUtil.matches(oldPassword, "$2a$10$encrypted_password"))
.thenReturn(true);
passwordUtil.when(() -> PasswordUtil.encode(newPassword))
.thenReturn("$2a$10$new_encrypted_password");
// 执行测试
Result<?> result = userService.changePassword(1L, oldPassword, newPassword);
// 验证结果
assertTrue(result.isSuccess());
assertEquals("密码修改成功", result.getMessage());
// 验证方法调用
verify(userMapper).updatePassword(eq(1L), eq("$2a$10$new_encrypted_password"));
}
}
@Test
void testChangePassword_OldPasswordIncorrect() {
String oldPassword = "wrongpassword";
String newPassword = "newpassword123";
// Mock 依赖方法
when(userMapper.findById(1L)).thenReturn(testUser);
try (MockedStatic<PasswordUtil> passwordUtil = mockStatic(PasswordUtil.class)) {
passwordUtil.when(() -> PasswordUtil.matches(oldPassword, "$2a$10$encrypted_password"))
.thenReturn(false);
// 执行测试
Result<?> result = userService.changePassword(1L, oldPassword, newPassword);
// 验证结果
assertFalse(result.isSuccess());
assertEquals(400, result.getCode());
assertEquals("原密码错误", result.getMessage());
// 验证不会更新密码
verify(userMapper, never()).updatePassword(anyLong(), anyString());
}
}
}

@ -0,0 +1,117 @@
package com.unilife.utils;
import com.unilife.model.dto.*;
import com.unilife.model.entity.*;
import java.time.LocalDateTime;
/**
*
* DTO
*/
public class TestDataBuilder {
/**
*
*/
public static User buildTestUser() {
User user = new User();
user.setId(1L);
user.setUsername("testuser");
user.setEmail("test@example.com");
user.setNickname("测试用户");
user.setPassword("$2a$10$encrypted_password");
user.setAvatar("avatar.jpg");
user.setStatus(1);
user.setCreatedAt(LocalDateTime.now());
user.setUpdatedAt(LocalDateTime.now());
return user;
}
/**
*
*/
public static Category buildTestCategory() {
Category category = new Category();
category.setId(1L);
category.setName("测试分类");
category.setDescription("测试分类描述");
category.setIcon("test-icon");
category.setSort(1);
category.setStatus(1);
category.setCreatedAt(LocalDateTime.now());
category.setUpdatedAt(LocalDateTime.now());
return category;
}
/**
*
*/
public static Post buildTestPost() {
Post post = new Post();
post.setId(1L);
post.setTitle("测试帖子");
post.setContent("测试帖子内容");
post.setUserId(1L);
post.setCategoryId(1L);
post.setLikeCount(0);
post.setViewCount(0);
post.setCommentCount(0);
post.setCreatedAt(LocalDateTime.now());
post.setUpdatedAt(LocalDateTime.now());
return post;
}
/**
*
*/
public static Resource buildTestResource() {
Resource resource = new Resource();
resource.setId(1L);
resource.setTitle("测试资源");
resource.setDescription("测试资源描述");
resource.setFileName("test.pdf");
resource.setFileUrl("http://example.com/test.pdf");
resource.setFileSize(1024L);
resource.setFileType("pdf");
resource.setUserId(1L);
resource.setCategoryId(1L);
resource.setDownloadCount(0);
resource.setLikeCount(0);
resource.setCreatedAt(LocalDateTime.now());
resource.setUpdatedAt(LocalDateTime.now());
return resource;
}
/**
* DTO
*/
public static CreatePostDTO buildCreatePostDTO() {
CreatePostDTO dto = new CreatePostDTO();
dto.setTitle("新帖子标题");
dto.setContent("新帖子内容");
dto.setCategoryId(1L);
return dto;
}
/**
* DTO
*/
public static CreateUserDTO buildCreateUserDTO() {
CreateUserDTO dto = new CreateUserDTO();
dto.setUsername("newuser");
dto.setEmail("newuser@example.com");
dto.setNickname("新用户");
dto.setPassword("password123");
return dto;
}
/**
* ID
*/
public static User buildTestUser(Long id) {
User user = buildTestUser();
user.setId(id);
return user;
}
}

@ -0,0 +1,66 @@
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
h2:
console:
enabled: true
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
redis:
host: localhost
port: 6379
database: 1
timeout: 2000ms
mail:
host: smtp.example.com
port: 587
username: test@example.com
password: testpassword
properties:
mail:
smtp:
auth: true
starttls:
enable: true
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.unilife.model.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.unilife: DEBUG
org.springframework.web: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
# JWT配置
jwt:
secret: test-secret-key-for-unit-testing-purposes-only
expiration: 3600000
# 文件上传配置
file:
upload:
path: /tmp/unilife-test/uploads/
max-size: 10MB
# 测试特定配置
test:
mock:
enabled: true
database:
cleanup: true

@ -33,6 +33,65 @@ Value '1440' is outside of valid range for type java.lang.Byte
- 查看前端控制台错误
- 验证API返回数据
### 3. 点赞按钮样式优化
**问题描述**
已点赞状态的蓝色背景过于突兀,与整体设计不协调
**解决方案**
- 替换突兀的蓝色为柔和的紫色主题
- 使用半透明背景代替实色背景
- 添加悬停动效和平滑过渡
- 保持状态清晰可辨的同时更加优雅
### 4. 发布帖子功能优化
**问题描述**
原发布帖子界面简陋,仅支持纯文本,用户体验不佳
**优化内容**
- ✅ **集成Markdown编辑器**:使用`md-editor-v3`支持富文本编辑
- ✅ **改进界面设计**:现代化的对话框布局和样式
- ✅ **增强表单验证**:完整的客户端验证和错误提示
- ✅ **优化用户体验**:字数限制、清空功能、响应式设计
- ✅ **Markdown语法提示**:帮助用户了解支持的语法
**技术实现**
```bash
npm install md-editor-v3 --save
```
**新增功能**
- 实时预览Markdown内容
- 工具栏支持各种格式化操作
- 支持代码高亮、表格、链接等
- 字数统计和限制标题最多100字符
- 表单验证和错误处理
- 响应式设计适配移动端
### 5. 帖子详情页面优化
**问题描述**
帖子详情页面UI不够美观且不支持Markdown内容渲染
**优化内容**
- ✅ **支持Markdown渲染**:使用`MdPreview`组件渲染Markdown内容
- ✅ **改善UI设计**:优化内容展示区域的样式和布局
- ✅ **增强可读性**:改进字体、间距、颜色等视觉元素
- ✅ **代码高亮**:支持代码块的语法高亮
- ✅ **表格样式**:美化表格显示效果
- ✅ **图片展示**:优化图片显示和圆角效果
**技术实现**
- 复用已安装的`md-editor-v3`库的预览组件
- 自定义CSS样式覆盖默认主题
- 使用紫色主题保持设计一致性
- 优化各种Markdown元素的显示效果
**视觉改进**
- 独立的白色背景内容区域
- 更好的标题层次和分割线
- 紫色主题的引用块和链接
- 优雅的代码块样式
- 现代化的表格设计
## 🚀 Stagewise开发工具集成
### 安装的包

Loading…
Cancel
Save