diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 8341c2c..6f6e85c 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -78,7 +78,7 @@ | G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | | G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | | G3-6 | 更新 bootstrap/shop DTO | [x] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | -| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 | +| G3-7 | 添加金币/商店测试 | [x] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 | 验证记录(2026-05-13):G3-1 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 验证记录(2026-05-13):G3-2 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/chest-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification-rules.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 @@ -86,6 +86,7 @@ 验证记录(2026-05-13):G3-4 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/shop/shop-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 验证记录(2026-05-13):G3-5 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/item-use-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 验证记录(2026-05-13):G3-6 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/app/bootstrap-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts src/__tests__/services/shop/shop-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 +验证记录(2026-05-13):G3-7 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/shop/shop-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts src/__tests__/services/gamification/item-use-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 ## Phase G4:广告恢复与订阅权益对齐 diff --git a/src/__tests__/services/gamification/item-use-service.test.ts b/src/__tests__/services/gamification/item-use-service.test.ts index f4946a5..636e447 100644 --- a/src/__tests__/services/gamification/item-use-service.test.ts +++ b/src/__tests__/services/gamification/item-use-service.test.ts @@ -89,10 +89,13 @@ describe('item-use-service', () => { clientRequestId: 'use-xp-1', }); + const activeUntilMs = Date.parse(result.effect.activeUntil ?? ''); + const expectedMs = Date.now() + 15 * 60 * 1000; expect(result.itemId).toBe('double_xp_potion'); expect(result.quantityRemaining).toBe(0); expect(result.effect.type).toBe('double_xp'); expect(result.effect.activeUntil).toEqual(expect.any(String)); + expect(Math.abs(activeUntilMs - expectedMs)).toBeLessThan(2_000); expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ activeUntil: expect.any(Date), metadata: { diff --git a/src/__tests__/services/shop/shop-service.test.ts b/src/__tests__/services/shop/shop-service.test.ts index 5ea0d71..eefdcfa 100644 --- a/src/__tests__/services/shop/shop-service.test.ts +++ b/src/__tests__/services/shop/shop-service.test.ts @@ -106,6 +106,29 @@ describe('shop-service', () => { })); }); + it('does not spend coins or grant inventory twice for duplicate purchases', async () => { + mockSelectQueue([ + [{ id: 'coin-tx-1' }], // spendCoins: existing spend transaction + [{ coinsBalance: 220 }], + [{ id: 'item-tx-1' }], // grantInventoryItem: existing item grant + [{ itemId: 'hint_feather', quantity: 1, activeUntil: null, metadata: null }], + ]); + + const result = await purchaseShopProduct('user-1', 'hint-feather', 'request-1'); + + expect(result.product.id).toBe('hint-feather'); + expect(result.coinsSpent).toBe(0); + expect(result.coinsBalance).toBe(220); + expect(result.item).toEqual({ + itemId: 'hint_feather', + quantity: 1, + activeUntil: null, + metadata: null, + }); + expect(db.insert).not.toHaveBeenCalled(); + expect(db.update).not.toHaveBeenCalled(); + }); + it('throws when the user does not have enough coins', async () => { mockSelectQueue([ [], // no existing spend