bindbox-game/internal/api/game/handler_test.go
2026-04-25 02:48:07 +08:00

347 lines
8.4 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}