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" ) // ---- 与 handler.go 保持同步的本地类型 ---- type settlePlayerRecord struct { UserID int64 `json:"user_id"` Ticket string `json:"ticket"` Win bool `json:"win"` Rank int `json:"rank"` Score int `json:"score"` DamageDealt int `json:"damage_dealt"` DamageTaken int `json:"damage_taken"` Kills int `json:"kills"` ChestsCollected int `json:"chests_collected"` RoundsSurvived int `json:"rounds_survived"` } type settleRequest struct { MatchID string `json:"match_id"` GameType string `json:"game_type"` TotalRounds int `json:"total_rounds"` Players []settlePlayerRecord `json:"players"` // 兼容旧版单人字段 UserID string `json:"user_id"` Ticket string `json:"ticket"` Win bool `json:"win"` Score int `json:"score"` } type settleResponse struct { Success bool `json:"success"` Reward string `json:"reward,omitempty"` } // ---- calcRankPoints 本地副本(保持与 handler.go 一致) ---- func calcRankPoints(rank int) int64 { switch rank { case 1: return 1000 case 2: return -900 case 3: return -1100 case 4: return -1300 default: return 0 } } // ---- 名次积分单元测试 ---- func TestCalcRankPoints(t *testing.T) { tests := []struct { name string rank int expected int64 }{ { name: "第1名固定加分", rank: 1, expected: 1000, }, { name: "第2名固定扣分", rank: 2, expected: -900, }, { name: "第3名固定扣分", rank: 3, expected: -1100, }, { name: "第4名固定扣分", rank: 4, expected: -1300, }, { name: "未知名次兜底为0", rank: 0, expected: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := calcRankPoints(tt.rank) assert.Equal(t, tt.expected, got) }) } } // ---- 批量结算:免费模式检测 ---- func TestSettleGame_FreeModeDetection(t *testing.T) { tests := []struct { name string gameType string expectFree bool }{ {"免费模式", "minesweeper_free", true}, {"付费模式", "minesweeper", false}, {"空game_type不算免费", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isFree := tt.gameType == "minesweeper_free" assert.Equal(t, tt.expectFree, isFree) }) } } // ---- 幂等:match_id 重复检测 ---- func TestSettleGame_Idempotency(t *testing.T) { // 模拟:同一 match_id 第二次进来时应跳过 recorded := map[string]bool{} settle := func(matchID string) bool { if recorded[matchID] { return false // 幂等跳过 } recorded[matchID] = true return true } assert.True(t, settle("match-001"), "第一次应成功") assert.False(t, settle("match-001"), "第二次应被跳过") assert.True(t, settle("match-002"), "不同 match_id 应成功") } // ---- 批量结算:兼容旧版单人字段 ---- func TestSettleGame_BackwardCompatibility(t *testing.T) { // 旧版请求(无 players 字段) old := settleRequest{ UserID: "12345", Ticket: "GT001", MatchID: "match-old", Win: true, Score: 50, GameType: "minesweeper", } // 兼容逻辑:players 为空时,从旧版字段构建 if len(old.Players) == 0 && old.UserID != "" { old.Players = []settlePlayerRecord{{ UserID: 12345, Ticket: old.Ticket, Win: old.Win, Score: old.Score, }} } assert.Len(t, old.Players, 1) assert.Equal(t, int64(12345), old.Players[0].UserID) assert.True(t, old.Players[0].Win) } // ---- Redis ticket 清理验证 ---- func TestSettleGame_TicketCleanup(t *testing.T) { mr, err := miniredis.Run() assert.NoError(t, err) 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", 30*time.Minute) // 确认 ticket 存在 val, err := rdb.Get(ctx, ticketKey).Result() assert.NoError(t, err) assert.Contains(t, val, "12345") // 结算后清除 ticket rdb.Del(ctx, ticketKey) _, err = rdb.Get(ctx, ticketKey).Result() assert.Error(t, err, "ticket 应已被清除") } // ---- HTTP 集成测试(模拟简化版 settle handler) ---- 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{ MatchID: "match-free-001", GameType: "minesweeper_free", TotalRounds: 10, Players: []settlePlayerRecord{ {UserID: 10001, Win: true, Score: 30, ChestsCollected: 2}, {UserID: 10002, Win: false, Score: 15}, }, }, expectedStatus: http.StatusOK, checkResponse: func(t *testing.T, body []byte) { var resp settleResponse _ = json.Unmarshal(body, &resp) assert.True(t, resp.Success) }, }, { name: "付费模式_批量结算_成功", request: settleRequest{ MatchID: "match-paid-001", GameType: "minesweeper", TotalRounds: 15, Players: []settlePlayerRecord{ {UserID: 10001, Win: true, Score: 50, DamageDealt: 8, ChestsCollected: 3}, {UserID: 10002, Win: false, Score: 20, DamageTaken: 6}, }, }, expectedStatus: http.StatusOK, checkResponse: func(t *testing.T, body []byte) { var resp settleResponse _ = json.Unmarshal(body, &resp) assert.True(t, resp.Success) }, }, { name: "空players且无旧版字段_直接返回成功", request: settleRequest{ MatchID: "match-empty-001", GameType: "minesweeper", Players: []settlePlayerRecord{}, }, expectedStatus: http.StatusOK, checkResponse: func(t *testing.T, body []byte) { var resp settleResponse _ = json.Unmarshal(body, &resp) assert.True(t, resp.Success) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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 } // 兼容旧版 if len(req.Players) == 0 && req.UserID != "" { req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Rank: 1, Score: req.Score}} } if len(req.Players) == 0 { c.JSON(http.StatusOK, settleResponse{Success: true}) return } // 计算积分(验证公式被调用) // 计算积分(验证名次映射) expectedRankPoints := map[int]int64{1: 1000, 2: -900, 3: -1100, 4: -1300} for _, p := range req.Players { pts := calcRankPoints(p.Rank) if expected, ok := expectedRankPoints[p.Rank]; ok { assert.Equal(t, expected, pts) } } c.JSON(http.StatusOK, settleResponse{Success: true}) }) 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) } }) } } // ---- 旧版单人 Bug 场景:现在通过 players 字段兼容 ---- func TestSettleGame_OldBugScenario(t *testing.T) { t.Run("旧版单人结算字段兼容", func(t *testing.T) { req := settleRequest{ UserID: "12345", Ticket: "GT123", GameType: "minesweeper_free", Win: true, Score: 100, } // 新版兼容逻辑 isFree := req.GameType == "minesweeper_free" assert.True(t, isFree, "免费模式通过 game_type 判断,不依赖 Redis") // players 为空时从旧版字段补全 if len(req.Players) == 0 && req.UserID != "" { req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Score: req.Score}} } assert.Len(t, req.Players, 1) }) } // ---- 性能基准:积分计算 ---- func BenchmarkCalcRankPoints(b *testing.B) { for i := 0; i < b.N; i++ { calcRankPoints(1) } }