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", StakeGiftId: 10001, 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":10001,"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.GetGiftId() != 10001 || 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"] != "10001" { 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 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 }