package game_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" ) // settleRequest 结算请求结构体(与 handler.go 保持一致) type settleRequest struct { UserID string `json:"user_id"` Ticket string `json:"ticket"` MatchID string `json:"match_id"` Win bool `json:"win"` Score int `json:"score"` GameType string `json:"game_type"` } // settleResponse 结算响应结构体 type settleResponse struct { Success bool `json:"success"` Reward string `json:"reward,omitempty"` } // TestSettleGame_FreeModeDetection 测试免费模式判断逻辑 // 这是核心测试:验证免费模式通过 game_type 参数判断,而不是依赖 Redis func TestSettleGame_FreeModeDetection(t *testing.T) { tests := []struct { name string gameType string ticketInRedis bool // 是否在 Redis 中存储 ticket expectedReward string // 预期的奖励消息 shouldBlock bool // 是否应该被拦截(免费模式) }{ { name: "免费模式_有ticket_应拦截", gameType: "minesweeper_free", ticketInRedis: true, expectedReward: "体验模式无奖励", shouldBlock: true, }, { name: "免费模式_无ticket_应拦截", gameType: "minesweeper_free", ticketInRedis: false, expectedReward: "体验模式无奖励", shouldBlock: true, }, { name: "付费模式_有ticket_应发奖", gameType: "minesweeper", ticketInRedis: true, expectedReward: "", // 付费模式会发放积分奖励 shouldBlock: false, }, { name: "付费模式_无ticket_应发奖", gameType: "minesweeper", ticketInRedis: false, expectedReward: "", // 付费模式会发放积分奖励 shouldBlock: false, }, { name: "空game_type_应发奖", gameType: "", ticketInRedis: false, expectedReward: "", // 空类型不是免费模式 shouldBlock: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 模拟判断逻辑 isFreeMode := tt.gameType == "minesweeper_free" if tt.shouldBlock { assert.True(t, isFreeMode, "免费模式应该被正确识别") } else { assert.False(t, isFreeMode, "非免费模式不应该被拦截") } }) } } // TestSettleGame_FreeModeWithRedis 测试 Redis ticket 不影响免费模式判断 func TestSettleGame_FreeModeWithRedis(t *testing.T) { // 1. 启动 miniredis mr, err := miniredis.Run() assert.NoError(t, err) defer mr.Close() rdb := redis.NewClient(&redis.Options{ Addr: mr.Addr(), }) ctx := context.Background() userID := "12345" ticket := "GT123456789" // 场景1: Redis 中有 ticket,但 game_type 是免费模式 t.Run("Redis有ticket但game_type是免费模式", func(t *testing.T) { // 存储 ticket 到 Redis ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute) req := settleRequest{ UserID: userID, Ticket: ticket, MatchID: "match-001", Win: true, Score: 100, GameType: "minesweeper_free", } // 直接从 req.GameType 判断 isFreeMode := req.GameType == "minesweeper_free" assert.True(t, isFreeMode, "应该识别为免费模式") // 清理 rdb.Del(ctx, ticketKey) }) // 场景2: Redis 中没有 ticket(已被删除),但 game_type 是免费模式 t.Run("Redis无ticket但game_type是免费模式", func(t *testing.T) { req := settleRequest{ UserID: userID, Ticket: ticket, MatchID: "match-002", Win: true, Score: 100, GameType: "minesweeper_free", } // 确认 Redis 中没有 ticket ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) _, err := rdb.Get(ctx, ticketKey).Result() assert.Error(t, err, "ticket 应该不存在") // 直接从 req.GameType 判断(修复后的逻辑) isFreeMode := req.GameType == "minesweeper_free" assert.True(t, isFreeMode, "即使 Redis 中没有 ticket,也应该识别为免费模式") }) // 场景3: Redis 中有 ticket 且是免费模式,但 game_type 参数为空(防止绕过) t.Run("Redis标记免费但game_type参数为空", func(t *testing.T) { ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute) req := settleRequest{ UserID: userID, Ticket: ticket, MatchID: "match-003", Win: true, Score: 100, GameType: "", // 恶意留空 } // 使用修复后的逻辑:以请求参数为准 isFreeMode := req.GameType == "minesweeper_free" assert.False(t, isFreeMode, "game_type 为空时不应识别为免费模式") // 注意:这里是一个潜在的安全风险,需要确保游戏服务器正确传递 game_type // 建议:可以增加双重校验,从 Redis 读取作为备份 rdb.Del(ctx, ticketKey) }) } // TestSettleGame_OldBugScenario 重现并验证旧 bug 已被修复 func TestSettleGame_OldBugScenario(t *testing.T) { // 模拟旧代码的问题场景 t.Run("旧bug重现_ticket被删除后误判为付费模式", func(t *testing.T) { mr, _ := miniredis.Run() defer mr.Close() rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) ctx := context.Background() userID := "12345" ticket := "GT123456789" ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) // 模拟场景: // 1. 用户进入免费游戏,ticket 存入 Redis rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute) // 2. 匹配成功后,ticket 被删除 rdb.Del(ctx, ticketKey) // 3. 游戏结算时尝试读取 ticket _, err := rdb.Get(ctx, ticketKey).Result() assert.Error(t, err, "ticket 应该已被删除") // --- 旧代码逻辑(有 bug)--- oldIsFreeMode := false if err == nil { // 只有在 Redis 中找到 ticket 时才能判断 // 这里 err != nil,所以 isFreeMode 保持 false } assert.False(t, oldIsFreeMode, "旧代码:ticket 被删除后无法判断免费模式") // --- 新代码逻辑(已修复)--- req := settleRequest{ UserID: userID, Ticket: ticket, GameType: "minesweeper_free", // 直接从请求参数获取 } newIsFreeMode := req.GameType == "minesweeper_free" assert.True(t, newIsFreeMode, "新代码:直接从 game_type 判断,不受 Redis 影响") }) } // TestSettleGame_Integration 集成测试(模拟完整的 HTTP 请求) func TestSettleGame_Integration(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string request settleRequest expectedStatus int checkResponse func(t *testing.T, body []byte) }{ { name: "免费模式结算_应返回体验模式无奖励", request: settleRequest{ UserID: "12345", Ticket: "GT123456789", MatchID: "match-001", Win: true, Score: 100, GameType: "minesweeper_free", }, expectedStatus: http.StatusOK, checkResponse: func(t *testing.T, body []byte) { var resp settleResponse err := json.Unmarshal(body, &resp) assert.NoError(t, err) assert.True(t, resp.Success) assert.Equal(t, "体验模式无奖励", resp.Reward) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 创建模拟的 handler(简化版,仅测试免费模式判断逻辑) router := gin.New() router.POST("/internal/game/settle", func(c *gin.Context) { var req settleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 核心逻辑:直接从请求参数判断 isFreeMode := req.GameType == "minesweeper_free" if isFreeMode { c.JSON(http.StatusOK, settleResponse{ Success: true, Reward: "体验模式无奖励", }) return } // 付费模式发奖逻辑(简化) c.JSON(http.StatusOK, settleResponse{ Success: true, Reward: "100积分", }) }) // 发送请求 body, _ := json.Marshal(tt.request) req, _ := http.NewRequest("POST", "/internal/game/settle", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, tt.expectedStatus, w.Code) if tt.checkResponse != nil { respBody, _ := io.ReadAll(w.Body) tt.checkResponse(t, respBody) } }) } } // BenchmarkFreeModeCheck 性能测试:对比新旧实现 func BenchmarkFreeModeCheck(b *testing.B) { // 旧实现:需要查询 Redis b.Run("旧实现_Redis查询", func(b *testing.B) { mr, _ := miniredis.Run() defer mr.Close() rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) ctx := context.Background() ticket := "GT123456789" ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket) rdb.Set(ctx, ticketKey, "12345:minesweeper_free", 30*time.Minute) b.ResetTimer() for i := 0; i < b.N; i++ { val, err := rdb.Get(ctx, ticketKey).Result() if err == nil { _ = val == "12345:minesweeper_free" } } }) // 新实现:直接比较字符串 b.Run("新实现_字符串比较", func(b *testing.B) { gameType := "minesweeper_free" b.ResetTimer() for i := 0; i < b.N; i++ { _ = gameType == "minesweeper_free" } }) }