827 lines
36 KiB
Go
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 http
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
func TestListGamesUsesAuthenticatedUserRegionAndReturnsEnvelope(t *testing.T) {
gameClient := &fakeGatewayGameClient{listResp: &gamev1.ListGamesResponse{
Games: []*gamev1.AppGame{{
GameId: "demo_rocket_001",
PlatformCode: "demo",
NameKey: "game.demo_rocket_001.name",
Name: "Rocket",
LaunchMode: "h5_popup",
Orientation: "portrait",
Enabled: true,
}},
ServerTimeMs: 1700000000000,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/games?scene=voice_room&room_id=room-1", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-App-Language", "en")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastList.GetUserId() != 42 || gameClient.lastList.GetRegionId() != 1001 || gameClient.lastList.GetScene() != "voice_room" || gameClient.lastList.GetRoomId() != "room-1" {
t.Fatalf("ListGames request mismatch: %+v", gameClient.lastList)
}
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := envelope.Data.(map[string]any)
games := data["games"].([]any)
first := games[0].(map[string]any)
if first["platform_code"] != "demo" || first["game_id"] != "demo_rocket_001" {
t.Fatalf("game response mismatch: %+v", first)
}
}
func TestListRecentGamesUsesAuthenticatedUserRegionAndLimit(t *testing.T) {
gameClient := &fakeGatewayGameClient{recentResp: &gamev1.ListGamesResponse{
Games: []*gamev1.AppGame{{
GameId: "viva_2",
PlatformCode: "vivagames",
Name: "Rich Forever",
LaunchMode: "h5_popup",
Orientation: "portrait",
Enabled: true,
}},
ServerTimeMs: 1700000000000,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/games/recent?scene=voice_room&room_id=room-1&limit=4", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-App-Language", "en")
request.Header.Set("X-App-Platform", "android")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastRecent.GetUserId() != 42 || gameClient.lastRecent.GetRegionId() != 1001 || gameClient.lastRecent.GetScene() != "voice_room" || gameClient.lastRecent.GetRoomId() != "room-1" || gameClient.lastRecent.GetPageSize() != 4 || gameClient.lastRecent.GetClientPlatform() != "android" {
t.Fatalf("ListRecentGames request mismatch: %+v", gameClient.lastRecent)
}
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := envelope.Data.(map[string]any)
games := data["games"].([]any)
first := games[0].(map[string]any)
if first["platform_code"] != "vivagames" || first["game_id"] != "viva_2" {
t.Fatalf("recent game response mismatch: %+v", first)
}
}
func TestListExploreWinnersReturnsSquareItems(t *testing.T) {
gameClient := &fakeGatewayGameClient{exploreWinnersResp: &gamev1.ListExploreWinnersResponse{
Winners: []*gamev1.ExploreWinner{{
WinId: "win-1",
GameCode: "game_dice",
GameId: "dice",
UserId: 7001,
DisplayName: "name",
AvatarUrl: "https://cdn.example/avatar.png",
CoinAmount: 306000,
WonAtMs: 1700000000000,
}},
ServerTimeMs: 1700000005000,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/games/explore-winners?limit=20", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-App-Code", "lalu")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastExploreWinners.GetMeta().GetActorUserId() != 42 || gameClient.lastExploreWinners.GetMeta().GetAppCode() != "lalu" || gameClient.lastExploreWinners.GetPageSize() != 20 {
t.Fatalf("ListExploreWinners request mismatch: %+v", gameClient.lastExploreWinners)
}
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := envelope.Data.(map[string]any)
items := data["items"].([]any)
first := items[0].(map[string]any)
if first["game_code"] != "game_dice" || first["user_id"] != "7001" || first["coin_amount"].(float64) != 306000 {
t.Fatalf("explore winner response mismatch: %+v", first)
}
if first["display_name"] != "user-7001" || first["avatar_url"] != "https://cdn.example/avatar.png" {
t.Fatalf("explore winner response mismatch: %+v", first)
}
}
func TestGetBridgeScriptReturnsUniversalScriptConfig(t *testing.T) {
gameClient := &fakeGatewayGameClient{bridgeResp: &gamev1.GetBridgeScriptResponse{
Config: &gamev1.GameBridgeScriptConfig{
BridgeScriptUrl: "https://cdn.example.test/game-bridge.js",
BridgeScriptVersion: "20260608.1",
BridgeScriptSha256: "abc",
},
ServerTimeMs: 1700000000000,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/games/bridge-script", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastBridge.GetMeta().GetActorUserId() != 42 {
t.Fatalf("GetBridgeScript request mismatch: %+v", gameClient.lastBridge)
}
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := envelope.Data.(map[string]any)
if data["bridge_script_url"] != "https://cdn.example.test/game-bridge.js" || data["bridgeScriptUrl"] != "https://cdn.example.test/game-bridge.js" || data["bridge_script_version"] != "20260608.1" || data["bridge_script_sha256"] != "abc" {
t.Fatalf("bridge script response mismatch: %+v", data)
}
}
func TestLaunchGameForwardsDisplayUserID(t *testing.T) {
gameClient := &fakeGatewayGameClient{launchResp: &gamev1.LaunchGameResponse{
SessionId: "game_sess_1",
LaunchUrl: "https://provider.example/h5?session_token=token",
ExpiresAtMs: 1700000900000,
Orientation: "portrait",
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
accessToken := signGatewayToken(t, "secret", 42)
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/demo_rocket_001/launch", bytes.NewReader([]byte(`{"scene":"voice_room","room_id":"room-1"}`)))
request.Header.Set("Authorization", "Bearer "+accessToken)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastLaunch.GetGameId() != "demo_rocket_001" || gameClient.lastLaunch.GetDisplayUserId() != "100001" || gameClient.lastLaunch.GetRoomId() != "room-1" || gameClient.lastLaunch.GetAccessToken() != accessToken {
t.Fatalf("LaunchGame request mismatch: %+v", gameClient.lastLaunch)
}
}
func TestDiceMatchRenewsAccessTokenWhenTokenNearExpiry(t *testing.T) {
userClient := &fakeUserAuthClient{}
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, userClient, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/dice/match", bytes.NewReader([]byte(`{"game_id":"dice","stake_coin":100}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithExpiresAt(t, "secret", 42, true, time.Now().Add(2*time.Minute)))
request.Header.Set("X-Device-ID", "dev-dice")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if userClient.lastAppHeartbeat == nil || userClient.lastAppHeartbeat.GetUserId() != 42 || userClient.lastAppHeartbeat.GetSessionId() != "sess-test" {
t.Fatalf("dice match must renew with authenticated user/session: %+v", userClient.lastAppHeartbeat)
}
if userClient.lastAppHeartbeat.GetMeta().GetDeviceId() != "dev-dice" {
t.Fatalf("dice match must forward device meta during token renew: %+v", userClient.lastAppHeartbeat.GetMeta())
}
if recorder.Header().Get("X-Hyapp-Access-Token") != "access-heartbeat" || recorder.Header().Get("X-Hyapp-Expires-In") != "1800" {
t.Fatalf("dice match must expose renewed token headers: %+v", recorder.Header())
}
}
func TestRPSConfigUsesRockSelfGame(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/games/rps/config?game_id=rock", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastGetDiceConfig == nil || gameClient.lastGetDiceConfig.GetGameId() != "rock" {
t.Fatalf("rps config must read rock self game config, got %+v", gameClient.lastGetDiceConfig)
}
}
func TestRPSMatchUsesRockSelfGameAndAuthenticatedRegion(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/rps/match", bytes.NewReader([]byte(`{"game_id":"rock","stake_coin":8000,"gesture":"paper"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastMatchDice == nil || gameClient.lastMatchDice.GetGameId() != "rock" || gameClient.lastMatchDice.GetUserId() != 42 || gameClient.lastMatchDice.GetRegionId() != 1001 || gameClient.lastMatchDice.GetStakeCoin() != 8000 || gameClient.lastMatchDice.GetRpsGesture() != "paper" {
t.Fatalf("rps match request mismatch: %+v", gameClient.lastMatchDice)
}
}
func TestRPSCreateUsesPreselectedGesture(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/rps/matches", bytes.NewReader([]byte(`{"game_id":"rock","stake_coin":500,"gesture":"Scissors"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastCreateDice == nil || gameClient.lastCreateDice.GetGameId() != "rock" || gameClient.lastCreateDice.GetRpsGesture() != "scissors" || gameClient.lastCreateDice.GetRegionId() != 1001 {
t.Fatalf("rps create request mismatch: %+v", gameClient.lastCreateDice)
}
}
func TestRPSMatchOperationsCarryRockBoundary(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
token := "Bearer " + signGatewayToken(t, "secret", 42)
// rps 的后续操作路径没有 body 里的 game_id 可依赖,必须由 gateway 根据路由固定传 rock 给 game-service 做 match 归属复核。
requests := []struct {
method string
path string
body string
check func()
}{
{
method: http.MethodPost,
path: "/api/v1/games/rps/matches/match-rock-1/join",
body: `{"gesture":"scissors"}`,
check: func() {
if gameClient.lastJoinDice == nil || gameClient.lastJoinDice.GetGameId() != "rock" || gameClient.lastJoinDice.GetRpsGesture() != "scissors" {
t.Fatalf("rps join must carry rock game_id: %+v", gameClient.lastJoinDice)
}
},
},
{
method: http.MethodGet,
path: "/api/v1/games/rps/matches/match-rock-1",
check: func() {
if gameClient.lastGetDice == nil || gameClient.lastGetDice.GetGameId() != "rock" {
t.Fatalf("rps get must carry rock game_id: %+v", gameClient.lastGetDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/rps/matches/match-rock-1/roll",
body: `{"gesture":"paper","game_id":"dice"}`,
check: func() {
if gameClient.lastRollDice == nil || gameClient.lastRollDice.GetGameId() != "rock" || gameClient.lastRollDice.GetRpsGesture() != "paper" {
t.Fatalf("rps roll must ignore body game_id and carry rock: %+v", gameClient.lastRollDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/rps/matches/match-rock-1/cancel",
check: func() {
if gameClient.lastCancelDice == nil || gameClient.lastCancelDice.GetGameId() != "rock" {
t.Fatalf("rps cancel must carry rock game_id: %+v", gameClient.lastCancelDice)
}
},
},
}
for _, item := range requests {
body := io.Reader(bytes.NewReader(nil))
if item.body != "" {
body = bytes.NewReader([]byte(item.body))
}
request := httptest.NewRequest(item.method, item.path, body)
request.Header.Set("Authorization", token)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("%s %s status mismatch: got %d body=%s", item.method, item.path, recorder.Code, recorder.Body.String())
}
item.check()
}
}
func TestDiceMatchOperationsCarryDiceBoundary(t *testing.T) {
gameClient := &fakeGatewayGameClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
token := "Bearer " + signGatewayToken(t, "secret", 42)
requests := []struct {
method string
path string
check func()
}{
{
method: http.MethodPost,
path: "/api/v1/games/dice/matches/match-dice-1/join",
check: func() {
if gameClient.lastJoinDice == nil || gameClient.lastJoinDice.GetGameId() != "dice" {
t.Fatalf("dice join must carry dice game_id: %+v", gameClient.lastJoinDice)
}
},
},
{
method: http.MethodGet,
path: "/api/v1/games/dice/matches/match-dice-1",
check: func() {
if gameClient.lastGetDice == nil || gameClient.lastGetDice.GetGameId() != "dice" {
t.Fatalf("dice get must carry dice game_id: %+v", gameClient.lastGetDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/dice/matches/match-dice-1/roll",
check: func() {
if gameClient.lastRollDice == nil || gameClient.lastRollDice.GetGameId() != "dice" {
t.Fatalf("dice roll must carry dice game_id: %+v", gameClient.lastRollDice)
}
},
},
{
method: http.MethodPost,
path: "/api/v1/games/dice/matches/match-dice-1/cancel",
check: func() {
if gameClient.lastCancelDice == nil || gameClient.lastCancelDice.GetGameId() != "dice" {
t.Fatalf("dice cancel must carry dice game_id: %+v", gameClient.lastCancelDice)
}
},
},
}
for _, item := range requests {
request := httptest.NewRequest(item.method, item.path, nil)
request.Header.Set("Authorization", token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("%s %s status mismatch: got %d body=%s", item.method, item.path, recorder.Code, recorder.Body.String())
}
item.check()
}
}
func TestCreateRoomRPSChallengeUsesAuthenticatedUserAndRegion(t *testing.T) {
gameClient := &fakeGatewayGameClient{roomRPSChallengeResp: &gamev1.RoomRPSChallengeResponse{
Challenge: &gamev1.RoomRPSChallenge{
ChallengeId: "rps-1",
RoomId: "room-1",
Status: "pending",
StakeGiftIdText: "Gifi-3",
Initiator: &gamev1.RoomRPSPlayer{UserId: 42, Gesture: "rock"},
},
ServerTimeMs: 1700000000000,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/room-rps/challenges/create", bytes.NewReader([]byte(`{"room_id":"room-1","gift_id":"Gifi-3","gesture":"Rock"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastCreateRoomRPS.GetUserId() != 42 || gameClient.lastCreateRoomRPS.GetRegionId() != 1001 || gameClient.lastCreateRoomRPS.GetRoomId() != "room-1" || gameClient.lastCreateRoomRPS.GetGiftIdText() != "Gifi-3" || gameClient.lastCreateRoomRPS.GetGiftId() != 0 || gameClient.lastCreateRoomRPS.GetGesture() != "rock" {
t.Fatalf("CreateRoomRPSChallenge request mismatch: %+v", gameClient.lastCreateRoomRPS)
}
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := envelope.Data.(map[string]any)
challenge := data["challenge"].(map[string]any)
if challenge["challenge_id"] != "rps-1" || challenge["stake_gift_id"] != "Gifi-3" {
t.Fatalf("room rps response mismatch: %+v", challenge)
}
}
func TestAcceptRoomRPSChallengeForwardsGestureOnly(t *testing.T) {
gameClient := &fakeGatewayGameClient{roomRPSChallengeResp: &gamev1.RoomRPSChallengeResponse{
Challenge: &gamev1.RoomRPSChallenge{
ChallengeId: "rps-1",
RoomId: "room-1",
Status: "finished",
Initiator: &gamev1.RoomRPSPlayer{UserId: 41, Gesture: "rock", Result: "lose"},
Challenger: &gamev1.RoomRPSPlayer{UserId: 42, Gesture: "paper", Result: "win"},
},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/room-rps/challenges/rps-1/accept", bytes.NewReader([]byte(`{"gesture":"paper","gift_id":999}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastAcceptRoomRPS.GetUserId() != 42 || gameClient.lastAcceptRoomRPS.GetChallengeId() != "rps-1" || gameClient.lastAcceptRoomRPS.GetGesture() != "paper" {
t.Fatalf("AcceptRoomRPSChallenge request mismatch: %+v", gameClient.lastAcceptRoomRPS)
}
}
func TestGetRoomRPSChallengeEnrichesBothPlayerProfiles(t *testing.T) {
gameClient := &fakeGatewayGameClient{roomRPSChallengeResp: &gamev1.RoomRPSChallengeResponse{
Challenge: &gamev1.RoomRPSChallenge{
ChallengeId: "rps-1",
RoomId: "room-1",
Status: "finished",
Initiator: &gamev1.RoomRPSPlayer{UserId: 41, Gesture: "rock", Result: "lose"},
Challenger: &gamev1.RoomRPSPlayer{UserId: 42, Gesture: "paper", Result: "win"},
},
}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
41: &userv1.User{UserId: 41, DisplayUserId: "410001", Username: "Alice", Avatar: "https://cdn.example/alice.png"},
42: &userv1.User{UserId: 42, DisplayUserId: "420001", Username: "Bob", Avatar: "https://cdn.example/bob.png"},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/games/room-rps/challenges/rps-1", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
// 房内猜拳 IM 也需要同一套展示字段HTTP 详情先锁住 gateway 返回的双方头像昵称,避免 Flutter 为基础展示再补拉资料。
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
challenge := envelope.Data.(map[string]any)["challenge"].(map[string]any)
initiatorUser := challenge["initiator"].(map[string]any)["user"].(map[string]any)
challengerUser := challenge["challenger"].(map[string]any)["user"].(map[string]any)
if initiatorUser["nickname"] != "Alice" || initiatorUser["avatar"] != "https://cdn.example/alice.png" {
t.Fatalf("initiator display profile mismatch: %+v", initiatorUser)
}
if challengerUser["nickname"] != "Bob" || challengerUser["avatar"] != "https://cdn.example/bob.png" {
t.Fatalf("challenger display profile mismatch: %+v", challengerUser)
}
}
func TestGameCallbackIsRawPublicResponse(t *testing.T) {
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/game-callbacks/demo/change_coin?nonce=1", bytes.NewReader([]byte(`{"provider_order_id":"p1"}`)))
request.Header.Set("X-App-Code", "lalu")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"code":0}` {
t.Fatalf("callback response must not use app envelope: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastCallback.GetPlatformCode() != "demo" || gameClient.lastCallback.GetOperation() != "change_coin" || string(gameClient.lastCallback.GetRawBody()) != `{"provider_order_id":"p1"}` || gameClient.lastCallback.GetQuery()["nonce"] != "1" {
t.Fatalf("callback request mismatch: %+v", gameClient.lastCallback)
}
}
func TestHotgameCompatFallbacksToHyappReyouCallback(t *testing.T) {
body := `{"gameId":"101","uid":"420001","token":"hyapp-token","sign":"s"}`
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/getUserInfo?nonce=1", bytes.NewReader([]byte(body)))
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0}` {
t.Fatalf("hotgame hyapp callback response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastCallback == nil || gameClient.lastCallback.GetPlatformCode() != "reyou" || gameClient.lastCallback.GetOperation() != "getUserInfo" || string(gameClient.lastCallback.GetRawBody()) != body || gameClient.lastCallback.GetQuery()["nonce"] != "1" {
t.Fatalf("hotgame hyapp callback request mismatch: %+v", gameClient.lastCallback)
}
}
func TestHotgameCompatProxiesChatapp3TokenWithoutGameCallback(t *testing.T) {
token := chatapp3HotgameToken("v1", 1001, "LIKEI", 4102444800000, 1700000000000)
body := `{"orderId":"o1","gameId":"13","roundId":"r1","uid":"1001","coin":12,"type":1,"token":"` + token + `","sign":"s"}`
var gotHost string
var gotPath string
var gotQuery string
var gotBody string
var gotContentType string
oldTransport := http.DefaultTransport
http.DefaultTransport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
gotHost = request.URL.Host
gotPath = request.URL.Path
gotQuery = request.URL.RawQuery
gotContentType = request.Header.Get("Content-Type")
raw, _ := io.ReadAll(request.Body)
gotBody = string(raw)
header := http.Header{}
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Header: header,
Body: io.NopCloser(bytes.NewReader([]byte(`{"errorCode":0,"data":{"source":"chatapp3"}}`))),
Request: request,
}, nil
})
defer func() { http.DefaultTransport = oldTransport }()
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/updateBalance?nonce=1", bytes.NewReader([]byte(body)))
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0,"data":{"source":"chatapp3"}}` {
t.Fatalf("hotgame chatapp3 proxy response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gotHost != "jvapi.haiyihy.com" || gotPath != "/go/updateBalance" || gotQuery != "nonce=1" || gotBody != body || gotContentType != "application/json" {
t.Fatalf("hotgame chatapp3 proxy request mismatch: host=%q path=%q query=%q content_type=%q body=%s", gotHost, gotPath, gotQuery, gotContentType, gotBody)
}
if gameClient.lastCallback != nil {
t.Fatalf("chatapp3 token must not enter hyapp game callback: %+v", gameClient.lastCallback)
}
}
func TestReyouGameCallbackProxiesChatapp3TokenWithoutGameCallback(t *testing.T) {
token := chatapp3HotgameToken("v1", 99123456, "LIKEI", 4102444800000, 1700000000000)
body := `{"gameId":"20","uid":"99123456","token":"` + token + `","sign":"s"}`
var gotHost string
var gotPath string
var gotQuery string
var gotBody string
var gotContentType string
oldTransport := http.DefaultTransport
http.DefaultTransport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
gotHost = request.URL.Host
gotPath = request.URL.Path
gotQuery = request.URL.RawQuery
gotContentType = request.Header.Get("Content-Type")
raw, _ := io.ReadAll(request.Body)
gotBody = string(raw)
header := http.Header{}
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Header: header,
Body: io.NopCloser(bytes.NewReader([]byte(`{"errorCode":0,"data":{"coin":1000}}`))),
Request: request,
}, nil
})
defer func() { http.DefaultTransport = oldTransport }()
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/game-callbacks/reyou/getUserInfo?nonce=1", bytes.NewReader([]byte(body)))
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0,"data":{"coin":1000}}` {
t.Fatalf("reyou chatapp3 proxy response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gotHost != "jvapi.haiyihy.com" || gotPath != "/go/getUserInfo" || gotQuery != "nonce=1" || gotBody != body || gotContentType != "application/json" {
t.Fatalf("reyou chatapp3 proxy request mismatch: host=%q path=%q query=%q content_type=%q body=%s", gotHost, gotPath, gotQuery, gotContentType, gotBody)
}
if gameClient.lastCallback != nil {
t.Fatalf("reyou chatapp3 token must not enter hyapp game callback: %+v", gameClient.lastCallback)
}
}
func chatapp3HotgameToken(version string, userID int64, sysOrigin string, expireMs int64, releaseMs int64) string {
payload := url.QueryEscape(version + ":" + strconv.FormatInt(userID, 10) + ":" + sysOrigin + ":" + strconv.FormatInt(expireMs, 10) + ":" + strconv.FormatInt(releaseMs, 10))
return "sign." + base64.StdEncoding.EncodeToString([]byte(payload))
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
type fakeGatewayGameClient struct {
listResp *gamev1.ListGamesResponse
recentResp *gamev1.ListGamesResponse
exploreWinnersResp *gamev1.ListExploreWinnersResponse
bridgeResp *gamev1.GetBridgeScriptResponse
launchResp *gamev1.LaunchGameResponse
callbackResp *gamev1.CallbackResponse
lastList *gamev1.ListGamesRequest
lastRecent *gamev1.ListRecentGamesRequest
lastExploreWinners *gamev1.ListExploreWinnersRequest
lastBridge *gamev1.GetBridgeScriptRequest
lastLaunch *gamev1.LaunchGameRequest
lastCallback *gamev1.CallbackRequest
lastGetDiceConfig *gamev1.GetDiceConfigRequest
lastMatchDice *gamev1.MatchDiceRequest
lastCreateDice *gamev1.CreateDiceMatchRequest
lastJoinDice *gamev1.JoinDiceMatchRequest
lastGetDice *gamev1.GetDiceMatchRequest
lastRollDice *gamev1.RollDiceMatchRequest
lastCancelDice *gamev1.CancelDiceMatchRequest
roomRPSChallengeResp *gamev1.RoomRPSChallengeResponse
lastListRoomRPS *gamev1.ListRoomRPSChallengesRequest
lastCreateRoomRPS *gamev1.CreateRoomRPSChallengeRequest
lastAcceptRoomRPS *gamev1.AcceptRoomRPSChallengeRequest
lastGetRoomRPS *gamev1.GetRoomRPSChallengeRequest
}
func (f *fakeGatewayGameClient) ListGames(_ context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
f.lastList = req
if f.listResp != nil {
return f.listResp, nil
}
return &gamev1.ListGamesResponse{}, nil
}
func (f *fakeGatewayGameClient) ListRecentGames(_ context.Context, req *gamev1.ListRecentGamesRequest) (*gamev1.ListGamesResponse, error) {
f.lastRecent = req
if f.recentResp != nil {
return f.recentResp, nil
}
return &gamev1.ListGamesResponse{}, nil
}
func (f *fakeGatewayGameClient) ListExploreWinners(_ context.Context, req *gamev1.ListExploreWinnersRequest) (*gamev1.ListExploreWinnersResponse, error) {
f.lastExploreWinners = req
if f.exploreWinnersResp != nil {
return f.exploreWinnersResp, nil
}
return &gamev1.ListExploreWinnersResponse{}, nil
}
func (f *fakeGatewayGameClient) GetBridgeScript(_ context.Context, req *gamev1.GetBridgeScriptRequest) (*gamev1.GetBridgeScriptResponse, error) {
f.lastBridge = req
if f.bridgeResp != nil {
return f.bridgeResp, nil
}
return &gamev1.GetBridgeScriptResponse{}, nil
}
func (f *fakeGatewayGameClient) LaunchGame(_ context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error) {
f.lastLaunch = req
if f.launchResp != nil {
return f.launchResp, nil
}
return &gamev1.LaunchGameResponse{}, nil
}
func (f *fakeGatewayGameClient) GetDiceConfig(_ context.Context, req *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
f.lastGetDiceConfig = req
return &gamev1.DiceConfigResponse{Config: &gamev1.DiceConfig{GameId: req.GetGameId()}}, nil
}
func (f *fakeGatewayGameClient) MatchDice(_ context.Context, req *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error) {
f.lastMatchDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CreateDiceMatch(_ context.Context, req *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastCreateDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) JoinDiceMatch(_ context.Context, req *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastJoinDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) GetDiceMatch(_ context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastGetDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) RollDiceMatch(_ context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastRollDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CancelDiceMatch(_ context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
f.lastCancelDice = req
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) GetRoomRPSConfig(context.Context, *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
return &gamev1.RoomRPSConfigResponse{}, nil
}
func (f *fakeGatewayGameClient) ListRoomRPSChallenges(_ context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) {
f.lastListRoomRPS = req
return &gamev1.ListRoomRPSChallengesResponse{}, nil
}
func (f *fakeGatewayGameClient) CreateRoomRPSChallenge(_ context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
f.lastCreateRoomRPS = req
if f.roomRPSChallengeResp != nil {
return f.roomRPSChallengeResp, nil
}
return &gamev1.RoomRPSChallengeResponse{}, nil
}
func (f *fakeGatewayGameClient) AcceptRoomRPSChallenge(_ context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
f.lastAcceptRoomRPS = req
if f.roomRPSChallengeResp != nil {
return f.roomRPSChallengeResp, nil
}
return &gamev1.RoomRPSChallengeResponse{}, nil
}
func (f *fakeGatewayGameClient) GetRoomRPSChallenge(_ context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
f.lastGetRoomRPS = req
if f.roomRPSChallengeResp != nil {
return f.roomRPSChallengeResp, nil
}
return &gamev1.RoomRPSChallengeResponse{}, nil
}
func (f *fakeGatewayGameClient) HandleCallback(_ context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
f.lastCallback = req
if f.callbackResp != nil {
return f.callbackResp, nil
}
return &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}, nil
}