2026-06-11 01:02:16 +08:00

369 lines
16 KiB
Go

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"
"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 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 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
bridgeResp *gamev1.GetBridgeScriptResponse
launchResp *gamev1.LaunchGameResponse
callbackResp *gamev1.CallbackResponse
lastList *gamev1.ListGamesRequest
lastRecent *gamev1.ListRecentGamesRequest
lastBridge *gamev1.GetBridgeScriptRequest
lastLaunch *gamev1.LaunchGameRequest
lastCallback *gamev1.CallbackRequest
}
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) 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, *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
return &gamev1.DiceConfigResponse{}, nil
}
func (f *fakeGatewayGameClient) MatchDice(context.Context, *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CreateDiceMatch(context.Context, *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) JoinDiceMatch(context.Context, *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) GetDiceMatch(context.Context, *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) RollDiceMatch(context.Context, *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, nil
}
func (f *fakeGatewayGameClient) CancelDiceMatch(context.Context, *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
return &gamev1.DiceMatchResponse{}, 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
}