diff --git a/.agents/skills/javascript-typescript-jest/SKILL.md b/.agents/skills/javascript-typescript-jest/SKILL.md new file mode 100644 index 0000000..9552d7c --- /dev/null +++ b/.agents/skills/javascript-typescript-jest/SKILL.md @@ -0,0 +1,44 @@ +--- +name: javascript-typescript-jest +description: 'Best practices for writing JavaScript/TypeScript tests using Jest, including mocking strategies, test structure, and common patterns.' +--- + +### Test Structure +- Name test files with `.test.ts` or `.test.js` suffix +- Place test files next to the code they test or in a dedicated `__tests__` directory +- Use descriptive test names that explain the expected behavior +- Use nested describe blocks to organize related tests +- Follow the pattern: `describe('Component/Function/Class', () => { it('should do something', () => {}) })` + +### Effective Mocking +- Mock external dependencies (APIs, databases, etc.) to isolate your tests +- Use `jest.mock()` for module-level mocks +- Use `jest.spyOn()` for specific function mocks +- Use `mockImplementation()` or `mockReturnValue()` to define mock behavior +- Reset mocks between tests with `jest.resetAllMocks()` in `afterEach` + +### Testing Async Code +- Always return promises or use async/await syntax in tests +- Use `resolves`/`rejects` matchers for promises +- Set appropriate timeouts for slow tests with `jest.setTimeout()` + +### Snapshot Testing +- Use snapshot tests for UI components or complex objects that change infrequently +- Keep snapshots small and focused +- Review snapshot changes carefully before committing + +### Testing React Components +- Use React Testing Library over Enzyme for testing components +- Test user behavior and component accessibility +- Query elements by accessibility roles, labels, or text content +- Use `userEvent` over `fireEvent` for more realistic user interactions + +## Common Jest Matchers +- Basic: `expect(value).toBe(expected)`, `expect(value).toEqual(expected)` +- Truthiness: `expect(value).toBeTruthy()`, `expect(value).toBeFalsy()` +- Numbers: `expect(value).toBeGreaterThan(3)`, `expect(value).toBeLessThanOrEqual(3)` +- Strings: `expect(value).toMatch(/pattern/)`, `expect(value).toContain('substring')` +- Arrays: `expect(array).toContain(item)`, `expect(array).toHaveLength(3)` +- Objects: `expect(object).toHaveProperty('key', value)` +- Exceptions: `expect(fn).toThrow()`, `expect(fn).toThrow(Error)` +- Mock functions: `expect(mockFn).toHaveBeenCalled()`, `expect(mockFn).toHaveBeenCalledWith(arg1, arg2)` diff --git a/.claude/skills/api-doc-maintainer/SKILL.md b/.claude/skills/api-doc-maintainer/SKILL.md new file mode 100644 index 0000000..531b7f7 --- /dev/null +++ b/.claude/skills/api-doc-maintainer/SKILL.md @@ -0,0 +1,194 @@ +--- +name: api-doc-maintainer +description: > + 当用户修改、新增或删除任何 API 接口时,同步更新 docs/api/ 下的 Markdown 文档。 + 触发场景:修改 Controller、DTO、Service 中的接口逻辑或参数,新增/删除 endpoint, + 变更响应结构,添加错误码。只要涉及 src/modules/*/ 下的接口代码改动,都应触发此技能。 +--- + +# API 文档维护技能 + +## 为什么文档同步如此重要 + +MemeMind 的客户端是 Cocos Creator 小游戏,客户端开发者依赖 `docs/api/` 下的文档来接入后端接口。文档滞后意味着客户端开发者要去读源码才能对接,这会严重拖慢开发节奏。所以每次接口变更,文档必须同步更新。 + +## 代码结构索引 + +在更新文档前,先确认变更涉及哪些文件: + +``` +src/modules/ +├── auth/ # 认证模块:wx-login、user assets、game-data +│ ├── auth.controller.ts +│ ├── dto/ +│ └── auth.service.ts +├── wechat-game/ # 游戏模块:levels、configs +│ ├── wechat-game.controller.ts +│ ├── dto/ +│ └── wechat-game.service.ts +└── share/ # 分享挑战模块:创建分享、加入、进度上报 + ├── share.controller.ts + ├── dto/ + └── share.service.ts +``` + +Controller 定义了路由和参数,DTO 定义了请求/响应的数据结构,Service 包含业务逻辑。三者共同决定文档内容。 + +## 文档存放与组织 + +文档统一放在 `docs/api/` 目录下,按功能模块分文件: + +| 模块 | 文档文件 | 对应代码 | +|------|----------|----------| +| 用户认证 | `auth-api.md` | `src/modules/auth/` | +| 游戏关卡 | `game-api.md` | `src/modules/wechat-game/` | +| 分享挑战 | `share-challenge-api.md` | `src/modules/share/` | +| 排行榜 | `leaderboard-api.md` | (预留) | + +新增模块时,创建对应的文档文件并在 `docs/api/README.md` 中注册索引。 + +## 文档结构 + +每个模块的 API 文档包含以下章节: + +```markdown +# 模块名称 API + +## Changelog +- YYYY-MM-DD: 变更说明 + +## 概述 +功能说明、前置依赖 + +## 认证方式 +(如需要)说明鉴权方式 + +## 通用响应格式 +(引用统一响应格式即可) + +## 接口列表 +### 1. 接口名称 +(按下方模板) + +## 错误码说明 +| 错误码 | HTTP 状态码 | 说明 | 触发条件 | + +## 接入流程 +典型业务场景的调用顺序 + +## 客户端示例 +Cocos Creator TypeScript 调用代码 +``` + +## 接口文档模板 + +根据接口类型智能选择模板,不需要填写不适用的字段。 + +### 查询类接口(GET,无请求体) + +```markdown +### N. 接口名称 + +**接口地址**:`GET /api/v1/{module}/{endpoint}` + +**是否需要认证**:是/否 + +**路径参数**(如有): +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| + +**查询参数**(如有): +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| + +**成功响应示例**: +```json +{ "success": true, "data": { ... }, "message": null, "timestamp": "..." } +``` + +**业务逻辑说明**: +- 规则 1 +``` + +### 写入类接口(POST/PUT/PATCH/DELETE,有请求体) + +```markdown +### N. 接口名称 + +**接口地址**:`POST /api/v1/{module}/{endpoint}` + +**是否需要认证**:是/否 + +**请求体**: +```json +{ + "field": "value" +} +``` + +**字段说明**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| + +**成功响应示例**: +```json +{ "success": true, "data": { ... }, "message": null, "timestamp": "..." } +``` + +**失败响应示例**(如有特殊错误场景): +```json +{ "success": false, "data": null, "message": "错误描述", "timestamp": "..." } +``` + +**业务逻辑说明**: +- 规则 1 +``` + +如果某个字段(如路径参数、查询参数)不适用于当前接口,直接省略该小节。 + +## 统一响应格式 + +所有 API 响应使用统一包装: + +```typescript +interface ApiResponse { + success: boolean; // 请求是否成功 + data: T | null; // 响应数据 + message: string | null; // 错误信息(成功时为 null) + timestamp: string; // ISO 8601 时间戳 +} +``` + +## 更新工作流 + +当接口代码发生变更时,按以下步骤操作: + +### 新增接口 +1. 在对应模块文档中按模板添加接口章节 +2. 从 Controller 读取路由路径和方法,从 DTO 读取参数定义 +3. 补充业务逻辑说明(来自 Service) +4. 添加 Cocos Creator 调用示例 +5. 更新 Changelog +6. 在 `docs/api/README.md` 索引中确认已注册 + +### 修改接口 +1. 对比代码变更,确定文档中哪些内容需要更新 +2. 更新受影响的字段:路径、参数、响应结构、业务逻辑 +3. 更新示例响应(确保与实际代码一致) +4. 在 Changelog 中记录变更 + +### 删除接口 +1. 在文档中标记为「已废弃」并注明替代方案,或直接移除 +2. 更新索引 +3. 在 Changelog 中记录 + +### 错误码变更 +1. 在错误码表中新增/修改/删除条目 +2. 注明触发条件 + +## 完成后输出 + +文档更新完成后,向用户简要报告: +1. 哪些文档被修改了 +2. 具体变更了什么内容 +3. 是否有需要客户端配合调整的地方 diff --git a/.claude/skills/javascript-typescript-jest b/.claude/skills/javascript-typescript-jest new file mode 120000 index 0000000..5ae3d1d --- /dev/null +++ b/.claude/skills/javascript-typescript-jest @@ -0,0 +1 @@ +../../.agents/skills/javascript-typescript-jest \ No newline at end of file diff --git a/.codebuddy/skills/javascript-typescript-jest b/.codebuddy/skills/javascript-typescript-jest new file mode 120000 index 0000000..5ae3d1d --- /dev/null +++ b/.codebuddy/skills/javascript-typescript-jest @@ -0,0 +1 @@ +../../.agents/skills/javascript-typescript-jest \ No newline at end of file diff --git a/.kilocode/skills/javascript-typescript-jest b/.kilocode/skills/javascript-typescript-jest new file mode 120000 index 0000000..5ae3d1d --- /dev/null +++ b/.kilocode/skills/javascript-typescript-jest @@ -0,0 +1 @@ +../../.agents/skills/javascript-typescript-jest \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..10df8c7 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,54 @@ +# MemeMind API 文档索引 + +> 本目录包含 MemeMind 微信小游戏所有 API 接口文档。 + +## 文档列表 + +| 模块 | 文档文件 | 说明 | 状态 | +|------|----------|------|------| +| 用户认证 | [auth-api.md](./auth-api.md) | 微信登录、JWT Token | 待编写 | +| 分享挑战 | [share-challenge-api.md](./share-challenge-api.md) | 创建分享、加入挑战、进度上报 | 已完成 | +| 游戏关卡 | [game-api.md](./game-api.md) | 关卡数据、答案验证 | 待编写 | +| 用户资产 | [user-assets-api.md](./user-assets-api.md) | 积分获取与消耗 | 待编写 | +| 排行榜 | [leaderboard-api.md](./leaderboard-api.md) | 排名、分数上报 | 预留 | + +## 文档维护规则 + +每当接口发生变更时,必须同步更新对应文档: + +- **新增接口** → 在对应模块文档中新增章节 +- **修改接口** → 更新现有章节内容 +- **删除接口** → 标记废弃或移除并通知客户端 + +详见:[API 文档维护技能](../../.claude/skills/api-doc-maintainer/SKILL.md)(项目级 Skill) + +## 通用规范 + +### 认证方式 +所有需要认证的接口通过 JWT Bearer Token 鉴权: + +``` +Authorization: Bearer +``` + +### 响应格式 +所有接口返回统一格式: + +```json +{ + "success": true, + "data": { ... }, + "message": null, + "timestamp": "2026-04-08T12:00:00.000Z" +} +``` + +### 基础路径 +``` +生产环境:https://api.mememind.com/api +本地开发:http://localhost:3000/api +``` + +## 客户端接入 + +客户端基于 Cocos Creator 开发,接入示例详见各模块文档。 diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md new file mode 100644 index 0000000..a269540 --- /dev/null +++ b/docs/api/share-challenge-api.md @@ -0,0 +1,741 @@ +# MemeMind 分享挑战功能 API 文档 + +> 本文档面向微信小游戏客户端(Cocos Creator)开发人员 + +## 目录 + +- [概述](#概述) +- [认证方式](#认证方式) +- [通用响应格式](#通用响应格式) +- [接口列表](#接口列表) +- [错误码说明](#错误码说明) +- [接入流程](#接入流程) +- [Cocos Creator 调用示例](#cocos-creator-调用示例) + +--- + +## 概述 + +分享挑战功能允许用户创建包含 6 个关卡的挑战链接,分享给好友。好友通过分享码加入挑战,独立完成关卡并上报进度。 + +### 新增功能点(当前分支) + +1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制 +2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间 +3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过 + +--- + +## 认证方式 + +除微信登录接口外,所有接口均需通过 JWT Token 进行身份认证。 + +### 请求头格式 + +``` +Authorization: Bearer +``` + +`token` 为微信登录接口返回的 JWT 令牌。 + +--- + +## 通用响应格式 + +所有接口均返回以下 JSON 结构: + +```typescript +{ + "success": boolean, // 请求是否成功 + "data": T | null, // 成功时返回的数据,失败时为 null + "message": string | null,// 错误信息,成功时为 null + "timestamp": string // 服务器响应时间(ISO 8601 格式) +} +``` + +### 成功响应示例 + +```json +{ + "success": true, + "data": { + "shareCode": "abc12345", + "title": "我的挑战", + "levelCount": 6 + }, + "message": null, + "timestamp": "2026-04-08T12:00:00.000Z" +} +``` + +### 失败响应示例 + +```json +{ + "success": false, + "data": null, + "message": "分享不存在或已过期", + "timestamp": "2026-04-08T12:00:00.000Z" +} +``` + +--- + +## 接口列表 + +### 1. 微信登录 + +获取用户身份令牌。 + +**接口地址**:`POST /api/v1/auth/wx-login` + +**是否需要认证**:否 + +**请求体**: + +```json +{ + "code": "微信 wx.login 返回的 code" +} +``` + +**响应数据**: + +```typescript +{ + token: string; // JWT 访问令牌,有效期 7 天 + user: { + id: string; // 用户 ID + nickname: string | null; // 用户昵称(微信昵称) + points: number; // 当前积分 + } +} +``` + +**成功响应示例**: + +```json +{ + "success": true, + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": "user_abc123", + "nickname": "游戏玩家", + "points": 10 + } + }, + "message": null, + "timestamp": "2026-04-08T12:00:00.000Z" +} +``` + +**客户端调用时机**: +- 用户首次进入游戏时调用 +- 小游戏冷启动时调用(建议缓存 token) + +--- + +### 2. 创建分享 + +创建一个新的分享挑战。 + +**接口地址**:`POST /api/v1/share` + +**是否需要认证**:是(JWT Bearer Token) + +**请求头**: +``` +Authorization: Bearer +Content-Type: application/json +``` + +**请求体**: + +```json +{ + "title": "我的挑战", // 分享标题,不超过 100 字符 + "levelIds": [ // 恰好 6 个关卡 ID + "level_id_1", + "level_id_2", + "level_id_3", + "level_id_4", + "level_id_5", + "level_id_6" + ] +} +``` + +**响应数据**: + +```typescript +{ + shareCode: string; // 8 位分享码,用于分享和加入 + title: string; // 分享标题 + levelCount: number; // 关卡数量(固定为 6) +} +``` + +**成功响应示例**: + +```json +{ + "success": true, + "data": { + "shareCode": "abc12345", + "title": "我的挑战", + "levelCount": 6 + }, + "message": null, + "timestamp": "2026-04-08T12:00:00.000Z" +} +``` + +**分享码生成规则**: +- 使用 nanoid 生成 8 位字符 +- 字符集为 a-z, A-Z, 0-9 +- 发生碰撞时最多重试 3 次 + +**客户端调用场景**: +- 用户点击「分享挑战」按钮时调用 +- 用户选择 6 个关卡后,生成分享码 +- 将分享码拼接为分享链接或二维码 + +--- + +### 3. 加入分享 + +通过分享码加入一个分享挑战,获取关卡数据。 + +**接口地址**:`POST /api/v1/share/{code}/join` + +**是否需要认证**:是(JWT Bearer Token) + +**路径参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| code | string | 是 | 分享码(8 位) | + +**响应数据**: + +```typescript +{ + shareCode: string; // 分享码 + title: string; // 分享标题 + levels: [ + { + id: string; // 关卡 ID + level: number; // 关卡序号(1-6) + imageUrl: string; // 关卡图片 URL + answer: string; // 正确答案 + hint1: string | null; // 提示 1 + hint2: string | null; // 提示 2 + hint3: string | null; // 提示 3 + sortOrder: number; // 排序顺序 + } + ] +} +``` + +**成功响应示例**: + +```json +{ + "success": true, + "data": { + "shareCode": "abc12345", + "title": "我的挑战", + "levels": [ + { + "id": "level_001", + "level": 1, + "imageUrl": "https://example.com/levels/1.png", + "answer": "答案1", + "hint1": "提示1", + "hint2": null, + "hint3": null, + "sortOrder": 1 + } + ] + }, + "message": null, + "timestamp": "2026-04-08T12:00:00.000Z" +} +``` + +**特殊逻辑**: +- 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录 +- 返回的关卡列表按 `levelIds` 创建时的顺序排列 + +**客户端调用场景**: +- 用户通过分享码/链接进入游戏时调用 +- 解析 URL 参数中的分享码,调用此接口获取关卡数据 + +--- + +### 4. 上报单关进度 + +用户在分享挑战中完成单关后,上报进度。 + +**接口地址**:`POST /api/v1/share/progress` + +**是否需要认证**:是(JWT Bearer Token) + +**请求体**: + +```json +{ + "shareCode": "abc12345", // 分享码 + "levelId": "level_001", // 关卡 ID + "passed": true, // 是否通过(true/false) + "timeSpent": 30 // 通关时间(秒) +} +``` + +**字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| shareCode | string | 是 | 分享码 | +| levelId | string | 是 | 关卡 ID | +| passed | boolean | 是 | 是否通过 | +| timeSpent | number | 是 | 通关时间(秒),最小值为 0 | + +**响应数据**: + +```typescript +{ + passed: boolean; // 是否通过 + timeLimit: number | null; // 该关卡时间限制(秒),null 表示无限制 + withinTimeLimit: boolean; // 是否在时间限制内通过 +} +``` + +**成功响应示例**: + +```json +{ + "success": true, + "data": { + "passed": true, + "timeLimit": 60, + "withinTimeLimit": true + }, + "message": null, + "timestamp": "2026-04-08T12:00:00.000Z" +} +``` + +**业务逻辑说明**: + +1. **首次通关记录**:只有首次 `passed=true` 才会记录通关时间 +2. **重复通关**:如果用户再次通关同一关卡(且之前已通过),返回之前记录的时间判断结果,不会覆盖 +3. **未通过**:可以多次上报 `passed=false`,更新通关时间记录 +4. **时间限制判断**: + - 如果关卡 `timeLimit` 为 `null`,`withinTimeLimit` 始终为 `true` + - 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true` + +**客户端调用场景**: +- 用户完成一个关卡后调用 +- 无论通关还是失败都需要调用 +- 失败时 `passed=false`,`timeSpent` 可以传入实际用时或关卡时间上限 + +--- + +## 错误码说明 + +| HTTP Status | message | 说明 | +|-------------|---------|------| +| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 | +| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 | +| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 | +| 401 | 未提供访问令牌 | 请求头缺少 Authorization | +| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期 | +| 404 | 分享不存在或已过期 | 分享码不存在或已被删除 | +| 404 | 关卡不存在 | levelId 不存在于 levels 表 | +| 500 | Internal server error | 服务器内部错误 | + +--- + +## 接入流程 + +### 整体流程 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 分享发起方 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. 用户选择 6 个关卡 │ +│ 2. 调用 POST /api/v1/share 创建分享 │ +│ 3. 获取 8 位分享码 │ +│ 4. 生成分享链接/二维码,分享给好友 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 分享接收方 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. 通过分享链接/二维码进入游戏 │ +│ 2. 解析分享码 │ +│ 3. 调用 POST /api/v1/auth/wx-login 获取/确认身份 │ +│ 4. 调用 POST /api/v1/share/{code}/join 加入挑战 │ +│ 5. 获取 6 个关卡数据,开始挑战 │ +│ 6. 每完成一关,调用 POST /api/v1/share/progress 上报进度 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 客户端状态管理建议 + +```typescript +// 建议在客户端维护以下状态 + +interface ShareChallengeState { + isInChallenge: boolean; // 是否正在参与分享挑战 + shareCode: string | null; // 当前分享码 + currentLevelIndex: number; // 当前关卡索引(0-5) + levels: LevelData[]; // 关卡数据 + progress: { + [levelId: string]: { + passed: boolean; + timeSpent: number; + withinTimeLimit: boolean; + } + }; +} +``` + +### 分享链接格式建议 + +``` +wechatminiprogram://pages/challenge?code=abc12345 +``` + +或在微信中使用 `wx.openUrl` 打开 H5 页面,H5 页面再跳转到小程序。 + +--- + +## Cocos Creator 调用示例 + +### 1. HTTP 请求工具类 + +```typescript +// HttpManager.ts +import { Color } from 'cc'; + +export interface ApiResponse { + success: boolean; + data: T | null; + message: string | null; + timestamp: string; +} + +export class HttpManager { + private baseUrl = 'https://your-api-domain.com/api'; + private token: string | null = null; + + setToken(token: string) { + this.token = token; + } + + getToken(): string | null { + return this.token; + } + + async request( + method: 'GET' | 'POST', + url: string, + body?: object + ): Promise> { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, this.baseUrl + url, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + + if (this.token) { + xhr.setRequestHeader('Authorization', `Bearer ${this.token}`); + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText)); + } else { + try { + const errorResp = JSON.parse(xhr.responseText); + reject(new Error(errorResp.message || '请求失败')); + } catch { + reject(new Error(`请求失败: ${xhr.status}`)); + } + } + }; + + xhr.onerror = () => { + reject(new Error('网络错误')); + }; + + if (body) { + xhr.send(JSON.stringify(body)); + } else { + xhr.send(); + } + }); + } + + // GET 请求 + async get(url: string): Promise> { + return this.request('GET', url); + } + + // POST 请求 + async post(url: string, body: object): Promise> { + return this.request('POST', url, body); + } +} + +export const httpManager = new HttpManager(); +``` + +### 2. 微信登录 + +```typescript +// 调用时机:小游戏启动时,或缓存的 token 过期时 +async function wxLogin() { + // 1. 调用微信 wx.login 获取 code + const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => { + wx.login({ + success: (res) => resolve({ code: res.code }), + fail: reject + }); + }); + + // 2. 将 code 发送到服务器换取 token + try { + const response = await httpManager.post<{ + token: string; + user: { id: string; nickname: string | null; points: number }; + }>('/v1/auth/wx-login', { + code: wxLoginRes.code + }); + + if (response.success && response.data) { + // 3. 缓存 token + httpManager.setToken(response.data.token); + wx.setStorageSync('jwt_token', response.data.token); + + console.log('登录成功,用户信息:', response.data.user); + return response.data; + } else { + throw new Error(response.message || '登录失败'); + } + } catch (error) { + console.error('登录失败:', error); + throw error; + } +} +``` + +### 3. 创建分享 + +```typescript +interface CreateShareResponse { + shareCode: string; + title: string; + levelCount: number; +} + +async function createShare(title: string, levelIds: string[]): Promise { + // 确保已登录 + if (!httpManager.getToken()) { + await wxLogin(); + } + + const response = await httpManager.post('/v1/share', { + title, + levelIds + }); + + if (response.success && response.data) { + console.log('分享创建成功:', response.data); + return response.data; + } else { + throw new Error(response.message || '创建分享失败'); + } +} + +// 使用示例 +const levelIds = ['level_1', 'level_2', 'level_3', 'level_4', 'level_5', 'level_6']; +const share = await createShare('一起来挑战!', levelIds); +console.log('分享码:', share.shareCode); +// 生成分享链接: `https://your-game.com/invite?code=${share.shareCode}` +``` + +### 4. 加入分享 + +```typescript +interface LevelData { + id: string; + level: number; + imageUrl: string; + answer: string; + hint1: string | null; + hint2: string | null; + hint3: string | null; + sortOrder: number; +} + +interface JoinShareResponse { + shareCode: string; + title: string; + levels: LevelData[]; +} + +async function joinShare(shareCode: string): Promise { + // 确保已登录 + if (!httpManager.getToken()) { + await wxLogin(); + } + + const response = await httpManager.post( + `/v1/share/${shareCode}/join` + ); + + if (response.success && response.data) { + console.log('加入分享成功:', response.data); + return response.data; + } else { + throw new Error(response.message || '加入分享失败'); + } +} + +// 使用示例 +// 从分享链接中获取分享码 +const shareCode = 'abc12345'; // 从 URL 参数解析 +const shareData = await joinShare(shareCode); +// 保存关卡数据,开始游戏 +``` + +### 5. 上报关卡进度 + +```typescript +interface ReportProgressResponse { + passed: boolean; + timeLimit: number | null; + withinTimeLimit: boolean; +} + +async function reportLevelProgress( + shareCode: string, + levelId: string, + passed: boolean, + timeSpent: number +): Promise { + // 确保已登录 + if (!httpManager.getToken()) { + await wxLogin(); + } + + const response = await httpManager.post( + '/v1/share/progress', + { + shareCode, + levelId, + passed, + timeSpent + } + ); + + if (response.success && response.data) { + console.log('进度上报成功:', response.data); + return response.data; + } else { + throw new Error(response.message || '进度上报失败'); + } +} + +// 使用示例 +async function onLevelComplete(levelId: string, timeSpent: number) { + const passed = true; // 根据游戏逻辑判断是否通过 + const result = await reportLevelProgress(this.shareCode, levelId, passed, timeSpent); + + if (result.passed) { + console.log(`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`); + if (result.timeLimit) { + console.log(`本关时间限制: ${result.timeLimit}秒`); + } + } +} +``` + +### 6. 启动流程示例 + +```typescript +// GameEntry.ts - 游戏入口脚本 +import { _decorator, Component } from 'cc'; +const { ccclass } = _decorator; + +@ccclass('GameEntry') +export class GameEntry extends Component { + async start() { + // 1. 检查本地缓存的 token + const cachedToken = wx.getStorageSync('jwt_token'); + if (cachedToken) { + httpManager.setToken(cachedToken); + } + + // 2. 尝试登录(无论是否有缓存 token) + try { + await wxLogin(); + console.log('登录成功'); + } catch (error) { + console.error('登录失败:', error); + } + + // 3. 检查是否有分享码(从启动参数或 URL 获取) + const launchOptions = wx.getLaunchOptionsSync(); + console.log('启动参数:', launchOptions); + + // 解析分享码(具体解析方式根据你的分享链接格式) + // if (launchOptions.query && launchOptions.query.code) { + // await joinShare(launchOptions.query.code); + // } + } +} +``` + +--- + +## 关卡数据结构 + +### LevelData 完整字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 关卡唯一标识 | +| level | number | 关卡序号(1-6) | +| imageUrl | string | 关卡图片 URL | +| answer | string | 正确答案 | +| hint1 | string \| null | 第 1 个提示 | +| hint2 | string \| null | 第 2 个提示 | +| hint3 | string \| null | 第 3 个提示 | +| sortOrder | number | 排序顺序 | + +### 关卡时间限制说明 + +`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。 + +如果需要在前端判断时间限制: +1. 用户完成关卡后调用 `reportLevelProgress` +2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制 +3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成 + +--- + +## 注意事项 + +1. **Token 有效期**:JWT Token 有效期为 7 天,客户端应缓存并在启动时使用 +2. **重复通关**:首次通关记录会被保留,重复通关不会覆盖之前的记录 +3. **分享码格式**:8 位字母数字组合,大小写敏感 +4. **关卡数量**:每次分享挑战固定包含 6 个关卡 +5. **网络异常**:建议在调用接口时显示 loading 状态,并处理网络异常情况 +6. **hint 字段**:`hint1/hint2/hint3` 可能为 `null`,表示该提示未配置 +7. **时间限制**:`timeLimit` 为 `null` 表示该关卡没有时间限制 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..1530e11 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "javascript-typescript-jest": { + "source": "github/awesome-copilot", + "sourceType": "github", + "computedHash": "0073d19ab0275eb6a8200aced6789a53744667bd717b5d1f44ce708a1740e5d4" + } + } +} diff --git a/skills/javascript-typescript-jest b/skills/javascript-typescript-jest new file mode 120000 index 0000000..fc8f32f --- /dev/null +++ b/skills/javascript-typescript-jest @@ -0,0 +1 @@ +../.agents/skills/javascript-typescript-jest \ No newline at end of file diff --git a/src/common/dto/api-response.dto.spec.ts b/src/common/dto/api-response.dto.spec.ts new file mode 100644 index 0000000..865582a --- /dev/null +++ b/src/common/dto/api-response.dto.spec.ts @@ -0,0 +1,65 @@ +import { ApiResponseDto } from './api-response.dto'; + +describe('ApiResponseDto', () => { + describe('success', () => { + it('should create a success response with data', () => { + const data = { id: '1', name: 'test' }; + const response = ApiResponseDto.success(data); + + expect(response.success).toBe(true); + expect(response.data).toEqual(data); + expect(response.message).toBeNull(); + expect(response.timestamp).toBeInstanceOf(Date); + }); + + it('should create a success response with null data', () => { + const response = ApiResponseDto.success(null); + + expect(response.success).toBe(true); + expect(response.data).toBeNull(); + expect(response.message).toBeNull(); + }); + + it('should create a success response with array data', () => { + const data = [1, 2, 3]; + const response = ApiResponseDto.success(data); + + expect(response.success).toBe(true); + expect(response.data).toEqual([1, 2, 3]); + }); + }); + + describe('error', () => { + it('should create an error response with message', () => { + const response = ApiResponseDto.error('Something went wrong'); + + expect(response.success).toBe(false); + expect(response.data).toBeNull(); + expect(response.message).toBe('Something went wrong'); + expect(response.timestamp).toBeInstanceOf(Date); + }); + + it('should create an error response with Chinese message', () => { + const response = ApiResponseDto.error('积分不足'); + + expect(response.success).toBe(false); + expect(response.message).toBe('积分不足'); + }); + }); + + describe('constructor', () => { + it('should set default message to null', () => { + const dto = new ApiResponseDto(true, 'data'); + + expect(dto.message).toBeNull(); + }); + + it('should set all properties correctly', () => { + const dto = new ApiResponseDto(false, null, 'error message'); + + expect(dto.success).toBe(false); + expect(dto.data).toBeNull(); + expect(dto.message).toBe('error message'); + }); + }); +}); diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts new file mode 100644 index 0000000..0e9fd33 --- /dev/null +++ b/src/common/filters/http-exception.filter.spec.ts @@ -0,0 +1,97 @@ +import { HttpExceptionFilter } from './http-exception.filter'; +import { + HttpException, + HttpStatus, + ArgumentsHost, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; + +describe('HttpExceptionFilter', () => { + let filter: HttpExceptionFilter; + + const mockJson = jest.fn(); + const mockStatus = jest.fn().mockReturnValue({ json: mockJson }); + const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus }); + const mockGetRequest = jest + .fn() + .mockReturnValue({ url: '/api/v1/test' }); + + const mockHost: ArgumentsHost = { + switchToHttp: () => ({ + getResponse: mockGetResponse, + getRequest: mockGetRequest, + }), + } as unknown as ArgumentsHost; + + beforeEach(() => { + filter = new HttpExceptionFilter(); + jest.clearAllMocks(); + mockStatus.mockReturnValue({ json: mockJson }); + }); + + it('should handle HttpException with string response', () => { + const exception = new HttpException('Not Found', HttpStatus.NOT_FOUND); + + filter.catch(exception, mockHost); + + expect(mockStatus).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + data: null, + path: '/api/v1/test', + }), + ); + }); + + it('should handle BadRequestException with object response', () => { + const exception = new BadRequestException('参数错误'); + + filter.catch(exception, mockHost); + + expect(mockStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + path: '/api/v1/test', + }), + ); + }); + + it('should handle NotFoundException', () => { + const exception = new NotFoundException('资源不存在'); + + filter.catch(exception, mockHost); + + expect(mockStatus).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + }); + + it('should handle generic Error with 500 status', () => { + const exception = new Error('Unexpected error'); + + filter.catch(exception, mockHost); + + expect(mockStatus).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: 'Unexpected error', + path: '/api/v1/test', + }), + ); + }); + + it('should handle unknown exception with 500 status', () => { + filter.catch('unexpected string error', mockHost); + + expect(mockStatus).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: 'Internal server error', + path: '/api/v1/test', + }), + ); + }); +}); diff --git a/src/common/guards/jwt-auth.guard.spec.ts b/src/common/guards/jwt-auth.guard.spec.ts new file mode 100644 index 0000000..8718b45 --- /dev/null +++ b/src/common/guards/jwt-auth.guard.spec.ts @@ -0,0 +1,79 @@ +import { JwtAuthGuard, JwtPayload } from './jwt-auth.guard'; +import { JwtService } from '@nestjs/jwt'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; + +describe('JwtAuthGuard', () => { + let guard: JwtAuthGuard; + + const mockJwtService = { + verifyAsync: jest.fn(), + }; + + beforeEach(() => { + guard = new JwtAuthGuard(mockJwtService as unknown as JwtService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + function createMockContext(authHeader?: string): ExecutionContext { + const mockRequest: Record = { + headers: authHeader ? { authorization: authHeader } : {}, + }; + + return { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as unknown as ExecutionContext; + } + + it('should allow access with valid Bearer token', async () => { + const payload: JwtPayload = { sub: 'user-1', openid: 'wx-openid' }; + mockJwtService.verifyAsync.mockResolvedValue(payload); + + const context = createMockContext('Bearer valid-token'); + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token'); + }); + + it('should attach user payload to request', async () => { + const payload: JwtPayload = { sub: 'user-1', openid: 'wx-openid' }; + mockJwtService.verifyAsync.mockResolvedValue(payload); + + const context = createMockContext('Bearer valid-token'); + const request = context.switchToHttp().getRequest() as Record; + await guard.canActivate(context); + + expect(request.user).toEqual(payload); + }); + + it('should throw UnauthorizedException when no Authorization header', async () => { + const context = createMockContext(); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when token type is not Bearer', async () => { + const context = createMockContext('Basic some-token'); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when token is invalid', async () => { + mockJwtService.verifyAsync.mockRejectedValue(new Error('invalid token')); + + const context = createMockContext('Bearer invalid-token'); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); +}); diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts new file mode 100644 index 0000000..56ee77f --- /dev/null +++ b/src/modules/auth/auth.controller.spec.ts @@ -0,0 +1,120 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { ApiResponseDto } from '../../common/dto/api-response.dto'; +import type { JwtPayload } from '../../common/guards/jwt-auth.guard'; + +describe('AuthController', () => { + let controller: AuthController; + + const mockUser: JwtPayload = { + sub: 'user-uuid-1', + openid: 'wx-openid-123', + }; + + const mockAuthService = { + wxLogin: jest.fn(), + getUserAssets: jest.fn(), + consumePoint: jest.fn(), + earnPoint: jest.fn(), + getGameData: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { provide: JwtService, useValue: { verifyAsync: jest.fn() } }, + ], + }).compile(); + + controller = module.get(AuthController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('wxLogin', () => { + it('should return success response with token and user info', async () => { + const loginResponse = { + token: 'jwt-token', + user: { id: 'user-uuid-1', nickname: 'Test', points: 10 }, + }; + mockAuthService.wxLogin.mockResolvedValue(loginResponse); + + const result = await controller.wxLogin({ code: 'wx-code-123' }); + + expect(result).toBeInstanceOf(ApiResponseDto); + expect(result.success).toBe(true); + expect(result.data).toEqual(loginResponse); + expect(mockAuthService.wxLogin).toHaveBeenCalledWith('wx-code-123'); + }); + }); + + describe('getUserAssets', () => { + it('should return success response with user points', async () => { + mockAuthService.getUserAssets.mockResolvedValue({ points: 10 }); + + const result = await controller.getUserAssets(mockUser); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ points: 10 }); + expect(mockAuthService.getUserAssets).toHaveBeenCalledWith('user-uuid-1'); + }); + }); + + describe('consumePoint', () => { + it('should return success response with updated points', async () => { + mockAuthService.consumePoint.mockResolvedValue({ points: 9 }); + const dto = { reason: 'hint_unlock' as const, levelId: 'level-1', hintIndex: 2 }; + + const result = await controller.consumePoint(mockUser, dto); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ points: 9 }); + expect(mockAuthService.consumePoint).toHaveBeenCalledWith( + 'user-uuid-1', + dto, + ); + }); + }); + + describe('earnPoint', () => { + it('should return success response with updated points', async () => { + mockAuthService.earnPoint.mockResolvedValue({ points: 11 }); + const dto = { + reason: 'level_complete' as const, + levelId: 'level-1', + timeSpent: 30, + }; + + const result = await controller.earnPoint(mockUser, dto); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ points: 11 }); + expect(mockAuthService.earnPoint).toHaveBeenCalledWith( + 'user-uuid-1', + dto, + ); + }); + }); + + describe('getGameData', () => { + it('should return success response with game data', async () => { + const gameData = { + user: { id: 'user-uuid-1', points: 10 }, + completedLevelIds: ['level-1', 'level-2'], + }; + mockAuthService.getGameData.mockResolvedValue(gameData); + + const result = await controller.getGameData(mockUser); + + expect(result.success).toBe(true); + expect(result.data).toEqual(gameData); + expect(mockAuthService.getGameData).toHaveBeenCalledWith('user-uuid-1'); + }); + }); +}); diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..997b682 --- /dev/null +++ b/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,293 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; +import axios from 'axios'; +import { AuthService } from './auth.service'; +import { UserRepository } from './repositories/user.repository'; +import { UserLevelProgressRepository } from './repositories/user-level-progress.repository'; +import { User } from './entities/user.entity'; +import { UserLevelProgress } from './entities/user-level-progress.entity'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('AuthService', () => { + let service: AuthService; + + const mockUser: User = { + id: 'user-uuid-1', + openid: 'wx-openid-123', + sessionKey: 'session-key-abc', + nickname: 'TestUser', + avatarUrl: null, + points: 10, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockLevelProgress: UserLevelProgress = { + id: 'progress-uuid-1', + userId: 'user-uuid-1', + levelId: 'level-1', + user: mockUser, + timeSpent: 30, + completedAt: new Date('2026-01-02'), + }; + + const mockUserRepository = { + findById: jest.fn(), + findByOpenid: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockUserLevelProgressRepository = { + findByUserId: jest.fn(), + findByUserAndLevel: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockJwtService = { + signAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: string) => { + const config: Record = { + WX_APPID: 'test-appid', + WX_SECRET: 'test-secret', + }; + return config[key] ?? defaultValue ?? ''; + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: JwtService, useValue: mockJwtService }, + { provide: UserRepository, useValue: mockUserRepository }, + { + provide: UserLevelProgressRepository, + useValue: mockUserLevelProgressRepository, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('wxLogin', () => { + it('should create a new user and return JWT token on first login', async () => { + const newUser = { ...mockUser, points: 10 }; + mockedAxios.get.mockResolvedValue({ + data: { openid: 'wx-openid-123', session_key: 'session-key-abc' }, + }); + mockUserRepository.findByOpenid.mockResolvedValue(null); + mockUserRepository.create.mockReturnValue(newUser); + mockUserRepository.save.mockResolvedValue(newUser); + mockJwtService.signAsync.mockResolvedValue('jwt-token-xyz'); + + const result = await service.wxLogin('wx-code-123'); + + expect(result.token).toBe('jwt-token-xyz'); + expect(result.user.id).toBe('user-uuid-1'); + expect(result.user.points).toBe(10); + expect(mockUserRepository.create).toHaveBeenCalledWith({ + openid: 'wx-openid-123', + sessionKey: 'session-key-abc', + points: 10, + }); + expect(mockJwtService.signAsync).toHaveBeenCalledWith({ + sub: 'user-uuid-1', + openid: 'wx-openid-123', + }); + }); + + it('should return existing user and update session_key on repeat login', async () => { + const existingUser = { ...mockUser }; + const updatedUser = { ...existingUser, sessionKey: 'new-session-key' }; + mockedAxios.get.mockResolvedValue({ + data: { openid: 'wx-openid-123', session_key: 'new-session-key' }, + }); + mockUserRepository.findByOpenid.mockResolvedValue(existingUser); + mockUserRepository.save.mockResolvedValue(updatedUser); + mockJwtService.signAsync.mockResolvedValue('jwt-token-abc'); + + const result = await service.wxLogin('wx-code-456'); + + expect(result.token).toBe('jwt-token-abc'); + expect(result.user.id).toBe('user-uuid-1'); + expect(mockUserRepository.create).not.toHaveBeenCalled(); + expect(mockUserRepository.save).toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException when WeChat API returns error', async () => { + mockedAxios.get.mockResolvedValue({ + data: { errcode: 40029, errmsg: 'invalid code' }, + }); + + await expect(service.wxLogin('invalid-code')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when WeChat API call fails', async () => { + mockedAxios.get.mockRejectedValue(new Error('Network error')); + + await expect(service.wxLogin('any-code')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('getUserAssets', () => { + it('should return user points', async () => { + mockUserRepository.findById.mockResolvedValue(mockUser); + + const result = await service.getUserAssets('user-uuid-1'); + + expect(result.points).toBe(10); + expect(mockUserRepository.findById).toHaveBeenCalledWith('user-uuid-1'); + }); + + it('should throw UnauthorizedException when user not found', async () => { + mockUserRepository.findById.mockResolvedValue(null); + + await expect(service.getUserAssets('nonexistent')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('consumePoint', () => { + it('should deduct 1 point and return updated points', async () => { + const user = { ...mockUser, points: 5 }; + const savedUser = { ...user, points: 4 }; + mockUserRepository.findById.mockResolvedValue(user); + mockUserRepository.save.mockResolvedValue(savedUser); + + const result = await service.consumePoint('user-uuid-1', { + reason: 'hint_unlock', + levelId: 'level-1', + hintIndex: 2, + }); + + expect(result.points).toBe(4); + expect(mockUserRepository.save).toHaveBeenCalled(); + }); + + it('should throw BadRequestException when points are 0', async () => { + mockUserRepository.findById.mockResolvedValue({ + ...mockUser, + points: 0, + }); + + await expect( + service.consumePoint('user-uuid-1', { + reason: 'hint_unlock', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw UnauthorizedException when user not found', async () => { + mockUserRepository.findById.mockResolvedValue(null); + + await expect( + service.consumePoint('nonexistent', { reason: 'hint_unlock' }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('earnPoint', () => { + const earnDto = { + reason: 'level_complete' as const, + levelId: 'level-1', + timeSpent: 30, + }; + + it('should award 1 point for first-time level completion', async () => { + const user = { ...mockUser, points: 10 }; + mockUserRepository.findById.mockResolvedValue(user); + mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(null); + mockUserLevelProgressRepository.create.mockReturnValue(mockLevelProgress); + mockUserLevelProgressRepository.save.mockResolvedValue(mockLevelProgress); + mockUserRepository.save.mockResolvedValue({ ...user, points: 11 }); + + const result = await service.earnPoint('user-uuid-1', earnDto); + + expect(result.points).toBe(11); + expect(mockUserLevelProgressRepository.create).toHaveBeenCalledWith({ + userId: 'user-uuid-1', + levelId: 'level-1', + timeSpent: 30, + }); + }); + + it('should not award duplicate points for already completed level', async () => { + const user = { ...mockUser, points: 10 }; + mockUserRepository.findById.mockResolvedValue(user); + mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue( + mockLevelProgress, + ); + + const result = await service.earnPoint('user-uuid-1', earnDto); + + expect(result.points).toBe(10); + expect(mockUserLevelProgressRepository.create).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException when user not found', async () => { + mockUserRepository.findById.mockResolvedValue(null); + + await expect( + service.earnPoint('nonexistent', earnDto), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('getGameData', () => { + it('should return user info and completed level IDs', async () => { + mockUserRepository.findById.mockResolvedValue(mockUser); + mockUserLevelProgressRepository.findByUserId.mockResolvedValue([ + { ...mockLevelProgress, levelId: 'level-1' }, + { ...mockLevelProgress, levelId: 'level-2' }, + ]); + + const result = await service.getGameData('user-uuid-1'); + + expect(result.user.id).toBe('user-uuid-1'); + expect(result.user.points).toBe(10); + expect(result.completedLevelIds).toEqual(['level-1', 'level-2']); + }); + + it('should return empty completedLevelIds when no progress', async () => { + mockUserRepository.findById.mockResolvedValue(mockUser); + mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]); + + const result = await service.getGameData('user-uuid-1'); + + expect(result.completedLevelIds).toEqual([]); + }); + + it('should throw UnauthorizedException when user not found', async () => { + mockUserRepository.findById.mockResolvedValue(null); + mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]); + + await expect(service.getGameData('nonexistent')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/src/modules/share/share.controller.spec.ts b/src/modules/share/share.controller.spec.ts new file mode 100644 index 0000000..7df8638 --- /dev/null +++ b/src/modules/share/share.controller.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { ShareController } from './share.controller'; +import { ShareService } from './share.service'; +import { ApiResponseDto } from '../../common/dto/api-response.dto'; +import type { JwtPayload } from '../../common/guards/jwt-auth.guard'; + +describe('ShareController', () => { + let controller: ShareController; + + const mockUser: JwtPayload = { + sub: 'user-uuid-1', + openid: 'wx-openid-123', + }; + + const mockShareService = { + createShare: jest.fn(), + joinShare: jest.fn(), + reportLevelProgress: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ShareController], + providers: [ + { provide: ShareService, useValue: mockShareService }, + { provide: JwtService, useValue: { verifyAsync: jest.fn() } }, + ], + }).compile(); + + controller = module.get(ShareController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createShare', () => { + it('should return success response with share details', async () => { + const shareResponse = { + shareCode: 'ABCD1234', + title: '我的挑战', + levelCount: 6, + }; + mockShareService.createShare.mockResolvedValue(shareResponse); + + const dto = { + title: '我的挑战', + levelIds: ['l1', 'l2', 'l3', 'l4', 'l5', 'l6'], + }; + const result = await controller.createShare(mockUser, dto); + + expect(result).toBeInstanceOf(ApiResponseDto); + expect(result.success).toBe(true); + expect(result.data).toEqual(shareResponse); + expect(mockShareService.createShare).toHaveBeenCalledWith( + 'user-uuid-1', + dto, + ); + }); + }); + + describe('joinShare', () => { + it('should return success response with share levels', async () => { + const joinResponse = { + shareCode: 'ABCD1234', + title: '我的挑战', + levels: [ + { + id: 'l1', + level: 1, + imageUrl: 'https://example.com/1.jpg', + answer: '答案', + hint1: null, + hint2: null, + hint3: null, + sortOrder: 0, + }, + ], + }; + mockShareService.joinShare.mockResolvedValue(joinResponse); + + const result = await controller.joinShare(mockUser, 'ABCD1234'); + + expect(result.success).toBe(true); + expect(result.data).toEqual(joinResponse); + expect(mockShareService.joinShare).toHaveBeenCalledWith( + 'user-uuid-1', + 'ABCD1234', + ); + }); + }); + + describe('reportLevelProgress', () => { + it('should return success response with progress result', async () => { + const progressResponse = { + passed: true, + timeLimit: 60, + withinTimeLimit: true, + }; + mockShareService.reportLevelProgress.mockResolvedValue(progressResponse); + + const dto = { + shareCode: 'ABCD1234', + levelId: 'level-1', + passed: true, + timeSpent: 30, + }; + const result = await controller.reportLevelProgress(mockUser, dto); + + expect(result.success).toBe(true); + expect(result.data).toEqual(progressResponse); + expect(mockShareService.reportLevelProgress).toHaveBeenCalledWith( + 'user-uuid-1', + dto, + ); + }); + }); +}); diff --git a/src/modules/share/share.service.spec.ts b/src/modules/share/share.service.spec.ts new file mode 100644 index 0000000..671b4d7 --- /dev/null +++ b/src/modules/share/share.service.spec.ts @@ -0,0 +1,409 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { ShareService } from './share.service'; +import { ShareConfigRepository } from './repositories/share-config.repository'; +import { ShareParticipantRepository } from './repositories/share-participant.repository'; +import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository'; +import { LevelRepository } from '../wechat-game/repositories/level.repository'; +import { Level } from '../wechat-game/entities/level.entity'; +import { ShareConfig } from './entities/share-config.entity'; +import { ShareParticipant } from './entities/share-participant.entity'; +import { ShareLevelProgress } from './entities/share-level-progress.entity'; + +// Mock nanoid to return predictable values +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => 'ABCD1234'), +})); + +describe('ShareService', () => { + let service: ShareService; + + const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({ + id: `level-${i + 1}`, + imageUrl: `https://example.com/meme${i + 1}.jpg`, + answer: `答案${i + 1}`, + hint1: `提示${i + 1}`, + hint2: null, + hint3: null, + sortOrder: i, + timeLimit: i === 0 ? 60 : null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + })); + + const mockShareConfig: ShareConfig = { + id: 'share-uuid-1', + shareCode: 'ABCD1234', + title: '我的挑战', + sharerId: 'user-uuid-1', + levelIds: mockLevels.map((l) => l.id), + sharer: {} as any, + participants: [], + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockParticipant: ShareParticipant = { + id: 'participant-uuid-1', + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', + shareConfig: {} as any, + participant: {} as any, + createdAt: new Date('2026-01-01'), + }; + + const mockShareConfigRepository = { + create: jest.fn(), + findByShareCode: jest.fn(), + }; + + const mockShareParticipantRepository = { + addParticipant: jest.fn(), + countByShareConfigId: jest.fn(), + findByShareConfigAndParticipant: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockShareLevelProgressRepository = { + findByParticipantAndLevel: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockLevelRepository = { + findByIds: jest.fn(), + findById: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ShareService, + { provide: ShareConfigRepository, useValue: mockShareConfigRepository }, + { + provide: ShareParticipantRepository, + useValue: mockShareParticipantRepository, + }, + { + provide: ShareLevelProgressRepository, + useValue: mockShareLevelProgressRepository, + }, + { provide: LevelRepository, useValue: mockLevelRepository }, + ], + }).compile(); + + service = module.get(ShareService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createShare', () => { + const createDto = { + title: '我的挑战', + levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'], + }; + + it('should create a share with valid 6 unique levels', async () => { + mockLevelRepository.findByIds.mockResolvedValue(mockLevels); + mockShareConfigRepository.findByShareCode.mockResolvedValue(null); + mockShareConfigRepository.create.mockResolvedValue(mockShareConfig); + + const result = await service.createShare('user-uuid-1', createDto); + + expect(result.shareCode).toBe('ABCD1234'); + expect(result.title).toBe('我的挑战'); + expect(result.levelCount).toBe(6); + }); + + it('should throw BadRequestException when level IDs have duplicates', async () => { + const duplicateDto = { + title: '测试', + levelIds: ['level-1', 'level-1', 'level-2', 'level-3', 'level-4', 'level-5'], + }; + + await expect( + service.createShare('user-uuid-1', duplicateDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException when some levels do not exist', async () => { + mockLevelRepository.findByIds.mockResolvedValue(mockLevels.slice(0, 4)); + + await expect( + service.createShare('user-uuid-1', createDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when share code generation fails', async () => { + mockLevelRepository.findByIds.mockResolvedValue(mockLevels); + // All 3 attempts find existing codes + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + + await expect( + service.createShare('user-uuid-1', createDto), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('joinShare', () => { + it('should return share details with ordered levels for a participant', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined); + mockLevelRepository.findByIds.mockResolvedValue(mockLevels); + + const result = await service.joinShare('user-uuid-2', 'ABCD1234'); + + expect(result.shareCode).toBe('ABCD1234'); + expect(result.title).toBe('我的挑战'); + expect(result.levels).toHaveLength(6); + expect(result.levels[0].level).toBe(1); + expect(result.levels[0].id).toBe('level-1'); + expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith( + 'share-uuid-1', + 'user-uuid-2', + ); + }); + + it('should not add participant when user is the sharer', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findByIds.mockResolvedValue(mockLevels); + + const result = await service.joinShare('user-uuid-1', 'ABCD1234'); + + expect(result.shareCode).toBe('ABCD1234'); + expect( + mockShareParticipantRepository.addParticipant, + ).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException when share code not found', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue(null); + + await expect( + service.joinShare('user-uuid-2', 'INVALID'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('reportLevelProgress', () => { + const reportDto = { + shareCode: 'ABCD1234', + levelId: 'level-1', + passed: true, + timeSpent: 30, + }; + + it('should create new progress record for first attempt', async () => { + const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 }; + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); + mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( + mockParticipant, + ); + mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + null, + ); + const newProgress: Partial = { + participantId: 'participant-uuid-1', + levelId: 'level-1', + passed: true, + timeSpent: 30, + }; + mockShareLevelProgressRepository.create.mockReturnValue(newProgress); + mockShareLevelProgressRepository.save.mockResolvedValue(newProgress); + + const result = await service.reportLevelProgress( + 'user-uuid-2', + reportDto, + ); + + expect(result.passed).toBe(true); + expect(result.timeLimit).toBe(60); + expect(result.withinTimeLimit).toBe(true); + }); + + it('should return existing result when already passed (idempotent)', async () => { + const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 }; + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); + mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( + mockParticipant, + ); + mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + { + id: 'progress-uuid-1', + participantId: 'participant-uuid-1', + levelId: 'level-1', + passed: true, + timeSpent: 25, + completedAt: new Date(), + } as ShareLevelProgress, + ); + + const result = await service.reportLevelProgress( + 'user-uuid-2', + reportDto, + ); + + expect(result.passed).toBe(true); + expect(result.withinTimeLimit).toBe(true); + expect(mockShareLevelProgressRepository.save).not.toHaveBeenCalled(); + }); + + it('should report withinTimeLimit=false when time exceeds limit', async () => { + const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 20 }; + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); + mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( + mockParticipant, + ); + mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + null, + ); + const newProgress: Partial = { + participantId: 'participant-uuid-1', + levelId: 'level-1', + passed: true, + timeSpent: 30, + }; + mockShareLevelProgressRepository.create.mockReturnValue(newProgress); + mockShareLevelProgressRepository.save.mockResolvedValue(newProgress); + + const result = await service.reportLevelProgress('user-uuid-2', { + ...reportDto, + timeSpent: 30, + }); + + expect(result.passed).toBe(true); + expect(result.withinTimeLimit).toBe(false); + }); + + it('should report withinTimeLimit=true when level has no time limit', async () => { + const levelNoTimeLimit = { ...mockLevels[1], timeLimit: null }; + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit); + mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( + mockParticipant, + ); + mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + null, + ); + const newProgress: Partial = { + participantId: 'participant-uuid-1', + levelId: 'level-2', + passed: true, + timeSpent: 999, + }; + mockShareLevelProgressRepository.create.mockReturnValue(newProgress); + mockShareLevelProgressRepository.save.mockResolvedValue(newProgress); + + const result = await service.reportLevelProgress('user-uuid-2', { + shareCode: 'ABCD1234', + levelId: 'level-2', + passed: true, + timeSpent: 999, + }); + + expect(result.withinTimeLimit).toBe(true); + }); + + it('should create participant if not exists when reporting progress', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(mockLevels[0]); + mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( + null, + ); + const createdParticipant = { ...mockParticipant }; + mockShareParticipantRepository.create.mockReturnValue(createdParticipant); + mockShareParticipantRepository.save.mockResolvedValue(createdParticipant); + mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + null, + ); + mockShareLevelProgressRepository.create.mockReturnValue({} as any); + mockShareLevelProgressRepository.save.mockResolvedValue({} as any); + + await service.reportLevelProgress('user-uuid-2', reportDto); + + expect(mockShareParticipantRepository.create).toHaveBeenCalledWith({ + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', + }); + expect(mockShareParticipantRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when share not found', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue(null); + + await expect( + service.reportLevelProgress('user-uuid-2', reportDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException when level not found', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(null); + + await expect( + service.reportLevelProgress('user-uuid-2', reportDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should update existing progress when not yet passed', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(mockLevels[0]); + mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( + mockParticipant, + ); + const existingProgress = { + id: 'progress-uuid-1', + participantId: 'participant-uuid-1', + levelId: 'level-1', + passed: false, + timeSpent: 15, + completedAt: new Date('2026-01-01'), + } as ShareLevelProgress; + mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + existingProgress, + ); + mockShareLevelProgressRepository.save.mockResolvedValue({ + ...existingProgress, + passed: true, + timeSpent: 30, + }); + + const result = await service.reportLevelProgress( + 'user-uuid-2', + reportDto, + ); + + expect(result.passed).toBe(true); + expect(mockShareLevelProgressRepository.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/wechat-game/wechat-game.controller.spec.ts b/src/modules/wechat-game/wechat-game.controller.spec.ts new file mode 100644 index 0000000..47835b6 --- /dev/null +++ b/src/modules/wechat-game/wechat-game.controller.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WechatGameController } from './wechat-game.controller'; +import { WechatGameService } from './wechat-game.service'; +import { ApiResponseDto } from '../../common/dto/api-response.dto'; + +describe('WechatGameController', () => { + let controller: WechatGameController; + + const mockWechatGameService = { + getAllConfigs: jest.fn(), + getConfigByKey: jest.fn(), + getAllLevels: jest.fn(), + getLevelById: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WechatGameController], + providers: [ + { provide: WechatGameService, useValue: mockWechatGameService }, + ], + }).compile(); + + controller = module.get(WechatGameController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getAllConfigs', () => { + it('should return success response with config list', async () => { + const configList = { + configs: [ + { + id: 'config-1', + configKey: 'game_speed', + configValue: '1.5', + description: null, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + total: 1, + }; + mockWechatGameService.getAllConfigs.mockResolvedValue(configList); + + const result = await controller.getAllConfigs(); + + expect(result).toBeInstanceOf(ApiResponseDto); + expect(result.success).toBe(true); + expect(result.data).toEqual(configList); + }); + }); + + describe('getConfigByKey', () => { + it('should return success response with config', async () => { + const config = { + id: 'config-1', + configKey: 'game_speed', + configValue: '1.5', + description: null, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + mockWechatGameService.getConfigByKey.mockResolvedValue(config); + + const result = await controller.getConfigByKey('game_speed'); + + expect(result.success).toBe(true); + expect(result.data).toEqual(config); + expect(mockWechatGameService.getConfigByKey).toHaveBeenCalledWith( + 'game_speed', + ); + }); + }); + + describe('getAllLevels', () => { + it('should return success response with level list', async () => { + const levelList = { + levels: [ + { + level: 1, + id: 'level-1', + imageUrl: 'https://example.com/1.jpg', + answer: '答案', + hint1: null, + hint2: null, + hint3: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + total: 1, + }; + mockWechatGameService.getAllLevels.mockResolvedValue(levelList); + + const result = await controller.getAllLevels(); + + expect(result.success).toBe(true); + expect(result.data).toEqual(levelList); + }); + }); + + describe('getLevelById', () => { + it('should return success response with level', async () => { + const level = { + level: 1, + id: 'level-1', + imageUrl: 'https://example.com/1.jpg', + answer: '答案', + hint1: null, + hint2: null, + hint3: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + mockWechatGameService.getLevelById.mockResolvedValue(level); + + const result = await controller.getLevelById('level-1'); + + expect(result.success).toBe(true); + expect(result.data).toEqual(level); + expect(mockWechatGameService.getLevelById).toHaveBeenCalledWith( + 'level-1', + ); + }); + }); +}); diff --git a/src/modules/wechat-game/wechat-game.service.spec.ts b/src/modules/wechat-game/wechat-game.service.spec.ts index 37eb36b..95a8538 100644 --- a/src/modules/wechat-game/wechat-game.service.spec.ts +++ b/src/modules/wechat-game/wechat-game.service.spec.ts @@ -1,41 +1,69 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; import { WechatGameService } from './wechat-game.service'; import { GameConfigRepository } from './repositories/game-config.repository'; -import { NotFoundException } from '@nestjs/common'; +import { LevelRepository } from './repositories/level.repository'; import { GameConfig } from './entities/game-config.entity'; +import { Level } from './entities/level.entity'; describe('WechatGameService', () => { let service: WechatGameService; - let repository: GameConfigRepository; const mockGameConfig: GameConfig = { - id: 'test-uuid', + id: 'config-uuid-1', configKey: 'game_speed', configValue: '1.5', description: 'Game speed multiplier', isActive: true, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), }; - const mockRepository = { + const mockLevel: Level = { + id: 'level-1', + imageUrl: 'https://example.com/meme1.jpg', + answer: '答案一', + hint1: '提示1', + hint2: '提示2', + hint3: null, + sortOrder: 0, + timeLimit: 60, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockLevel2: Level = { + id: 'level-2', + imageUrl: 'https://example.com/meme2.jpg', + answer: '答案二', + hint1: '提示A', + hint2: null, + hint3: null, + sortOrder: 1, + timeLimit: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockGameConfigRepository = { findActiveConfigs: jest.fn(), findByKey: jest.fn(), }; + const mockLevelRepository = { + findAllOrdered: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ WechatGameService, - { - provide: GameConfigRepository, - useValue: mockRepository, - }, + { provide: GameConfigRepository, useValue: mockGameConfigRepository }, + { provide: LevelRepository, useValue: mockLevelRepository }, ], }).compile(); service = module.get(WechatGameService); - repository = module.get(GameConfigRepository); }); afterEach(() => { @@ -44,18 +72,20 @@ describe('WechatGameService', () => { describe('getAllConfigs', () => { it('should return all active configs', async () => { - mockRepository.findActiveConfigs.mockResolvedValue([mockGameConfig]); + mockGameConfigRepository.findActiveConfigs.mockResolvedValue([ + mockGameConfig, + ]); const result = await service.getAllConfigs(); expect(result.configs).toHaveLength(1); expect(result.total).toBe(1); expect(result.configs[0].configKey).toBe('game_speed'); - expect(mockRepository.findActiveConfigs).toHaveBeenCalled(); + expect(result.configs[0].configValue).toBe('1.5'); }); it('should return empty array when no configs found', async () => { - mockRepository.findActiveConfigs.mockResolvedValue([]); + mockGameConfigRepository.findActiveConfigs.mockResolvedValue([]); const result = await service.getAllConfigs(); @@ -66,21 +96,74 @@ describe('WechatGameService', () => { describe('getConfigByKey', () => { it('should return config by key', async () => { - mockRepository.findByKey.mockResolvedValue(mockGameConfig); + mockGameConfigRepository.findByKey.mockResolvedValue(mockGameConfig); const result = await service.getConfigByKey('game_speed'); expect(result.configKey).toBe('game_speed'); expect(result.configValue).toBe('1.5'); - expect(mockRepository.findByKey).toHaveBeenCalledWith('game_speed'); + expect(mockGameConfigRepository.findByKey).toHaveBeenCalledWith( + 'game_speed', + ); }); it('should throw NotFoundException when config not found', async () => { - mockRepository.findByKey.mockResolvedValue(null); + mockGameConfigRepository.findByKey.mockResolvedValue(null); await expect(service.getConfigByKey('nonexistent')).rejects.toThrow( NotFoundException, ); }); }); + + describe('getAllLevels', () => { + it('should return all levels with 1-indexed level numbers', async () => { + mockLevelRepository.findAllOrdered.mockResolvedValue([ + mockLevel, + mockLevel2, + ]); + + const result = await service.getAllLevels(); + + expect(result.levels).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.levels[0].level).toBe(1); + expect(result.levels[0].id).toBe('level-1'); + expect(result.levels[0].answer).toBe('答案一'); + expect(result.levels[1].level).toBe(2); + expect(result.levels[1].id).toBe('level-2'); + }); + + it('should return empty array when no levels exist', async () => { + mockLevelRepository.findAllOrdered.mockResolvedValue([]); + + const result = await service.getAllLevels(); + + expect(result.levels).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + describe('getLevelById', () => { + it('should return level with correct level number', async () => { + mockLevelRepository.findAllOrdered.mockResolvedValue([ + mockLevel, + mockLevel2, + ]); + + const result = await service.getLevelById('level-2'); + + expect(result.id).toBe('level-2'); + expect(result.level).toBe(2); + expect(result.answer).toBe('答案二'); + }); + + it('should throw NotFoundException when level not found', async () => { + mockLevelRepository.findAllOrdered.mockResolvedValue([mockLevel]); + + await expect(service.getLevelById('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); });