package game import ( "context" "crypto/aes" "crypto/cipher" "crypto/md5" "encoding/base64" "encoding/hex" "encoding/json" "net/url" "strconv" "strings" "testing" "time" jwt "github.com/golang-jwt/jwt/v5" "google.golang.org/grpc" activityv1 "hyapp.local/api/proto/activity/v1" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/xerr" gamedomain "hyapp/services/game-service/internal/domain/game" ) func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "demo_rocket_001", PlatformCode: "demo", ProviderGameID: "rocket_001", GameName: "Rocket", Status: gamedomain.StatusActive, Orientation: "portrait", }, PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://provider.example/h5", }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "demo_rocket_001", Scene: gamedomain.SceneVoiceRoom, RoomID: "room_1", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") { t.Fatalf("launch result mismatch: %+v", result) } if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.RegionID != 100 || repo.session.LaunchTokenHash == "" { t.Fatalf("launch session was not persisted: %+v", repo.session) } repo.launchable.PlatformStatus = gamedomain.StatusMaintenance _, err = svc.LaunchGame(context.Background(), LaunchCommand{AppCode: "lalu", UserID: 42, GameID: "demo_rocket_001"}) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("maintenance game should reject launch, got %v", err) } } func TestListRecentGamesNormalizesQueryAndDefaultsLimit(t *testing.T) { repo := &fakeRepository{ recentGames: []gamedomain.AppGame{ {GameID: "viva_2", Name: "Rich Forever"}, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } games, serverTimeMS, err := svc.ListRecentGames(context.Background(), ListRecentGamesQuery{ UserID: 42, Scene: "", Language: " EN-US ", ClientPlatform: " Android ", }) if err != nil { t.Fatalf("ListRecentGames failed: %v", err) } if serverTimeMS != 1700000000000 || len(games) != 1 || games[0].GameID != "viva_2" { t.Fatalf("recent games response mismatch: time=%d games=%+v", serverTimeMS, games) } if repo.lastRecentQuery.AppCode != "lalu" || repo.lastRecentQuery.Scene != gamedomain.SceneVoiceRoom || repo.lastRecentQuery.PageSize != 4 || repo.lastRecentQuery.Language != "en-us" || repo.lastRecentQuery.ClientPlatform != "android" { t.Fatalf("recent query normalization mismatch: %+v", repo.lastRecentQuery) } } func TestGetBridgeScriptUsesFirstActivePlatformConfig(t *testing.T) { repo := &fakeRepository{ platforms: []gamedomain.Platform{ { AppCode: "lalu", PlatformCode: "vivagames", Status: gamedomain.StatusActive, SortOrder: 20, AdapterConfigJSON: `{"bridge_script_url":"https://cdn.example.test/bridge-viva.js","bridge_script_version":"20260608.2","bridge_script_sha256":"def"}`, }, { AppCode: "lalu", PlatformCode: "baishun", Status: gamedomain.StatusActive, SortOrder: 10, AdapterConfigJSON: `{"bridgeScriptUrl":"https://cdn.example.test/bridge.js","bridgeScriptVersion":"20260608.1","bridgeScriptSha256":"abc"}`, }, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.GetBridgeScript(context.Background(), " LALU ") if err != nil { t.Fatalf("GetBridgeScript failed: %v", err) } if result.URL != "https://cdn.example.test/bridge.js" || result.Version != "20260608.1" || result.SHA256 != "abc" || result.ServerTimeMS != 1700000000000 { t.Fatalf("bridge script mismatch: %+v", result) } if repo.lastListPlatformsAppCode != "lalu" || repo.lastListPlatformsStatus != gamedomain.StatusActive { t.Fatalf("ListPlatforms query mismatch: app=%q status=%q", repo.lastListPlatformsAppCode, repo.lastListPlatformsStatus) } } func TestLaunchGameBuildsYomiAuthURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "yomi_fish_001", PlatformCode: "yomi", ProviderGameID: "33", GameName: "Fish", Status: gamedomain.StatusActive, Orientation: "landscape", }, PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://api-testv2.yomigame.games/gate/gateway/auth", AdapterType: gamedomain.AdapterYomiV4, AdapterConfigJSON: `{"default_lang":"en","token_ttl_seconds":86400,"uid_mode":"user_id","game_level_uids":{"33":17},"plat_payload":"{\"env\":\"test\"}"}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "yomi_fish_001", RoomID: "room_1", AccessToken: "app_access_token_yomi", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.ExpiresAtMS != 1700086400000 { t.Fatalf("yomi launch must use adapter token ttl, got %+v", result) } parsed, err := url.Parse(result.LaunchURL) if err != nil { t.Fatalf("parse yomi launch url failed: %v", err) } query := parsed.Query() expected := map[string]string{ "gameUid": "33", "platUserId": "42", "lang": "en", "gameLevelUid": "17", "platRoomId": "room_1", "platAuthCode": "app_access_token_yomi", "platPayload": `{"env":"test"}`, } for key, want := range expected { if got := query.Get(key); got != want { t.Fatalf("yomi launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL) } } } func TestLaunchGameRequiresYomiAuthURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "yomi_fish_001", PlatformCode: "yomi", ProviderGameID: "33", GameName: "Fish", Status: gamedomain.StatusActive, }, PlatformStatus: gamedomain.StatusActive, AdapterType: gamedomain.AdapterYomiV4, AdapterConfigJSON: `{"default_lang":"en"}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } _, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, GameID: "yomi_fish_001", AccessToken: "app_access_token_yomi", }) if err == nil || !strings.Contains(xerr.MessageOf(err), "yomi auth url is required") { t.Fatalf("LaunchGame must reject missing yomi auth url, got %v", err) } } func TestLaunchGameBuildsLeaderCCURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "lingxian_rocket_001", PlatformCode: "lingxian", ProviderGameID: "101", GameName: "Rocket", Status: gamedomain.StatusActive, Orientation: "portrait", }, PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://games.leadercc.com/test/index.html", AdapterType: gamedomain.AdapterLeaderCCV1, AdapterConfigJSON: `{"default_lang":"zh-CN","token_ttl_seconds":86400,"uid_mode":"display_user_id"}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "lingxian_rocket_001", RoomID: "room_1", AccessToken: "app_access_token_lingxian", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.ExpiresAtMS != 1700086400000 { t.Fatalf("leadercc launch must use adapter token ttl, got %+v", result) } for _, part := range []string{"uid=420001", "token=app_access_token_lingxian", "lang=zh-CN", "roomid=room_1"} { if !strings.Contains(result.LaunchURL, part) { t.Fatalf("leadercc launch url missing %s: %s", part, result.LaunchURL) } } } func TestLaunchGameBuildsZeeOneURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "zeeone_gold_olympus", PlatformCode: "zeeone", ProviderGameID: "1021", GameName: "Gold of Olympus", Status: gamedomain.StatusActive, Orientation: "portrait", }, PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://fallback.example.invalid/game.html", AdapterType: gamedomain.AdapterZeeOneV1, AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"default_lang":"en","token_ttl_seconds":86400,"game_urls":{"1021":"https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html?merchant=0&platform=0"}}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "zeeone_gold_olympus", RoomID: "01", AccessToken: "app_access_token_zeeone", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.ExpiresAtMS != 1700086400000 { t.Fatalf("zeeone launch must use adapter token ttl, got %+v", result) } if !strings.HasPrefix(result.LaunchURL, "https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html?") { t.Fatalf("zeeone launch url must use per-game configured url: %s", result.LaunchURL) } for _, part := range []string{"sessionId=app_access_token_zeeone", "language=en", "merchant=307715", "platform=2032", "sid=01"} { if !strings.Contains(result.LaunchURL, part) { t.Fatalf("zeeone launch url missing %s: %s", part, result.LaunchURL) } } } func TestLaunchGameRequiresZeeOneLaunchURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "zeeone_unknown", PlatformCode: "zeeone", ProviderGameID: "9999", GameName: "Unknown ZeeOne Game", Status: gamedomain.StatusActive, }, PlatformStatus: gamedomain.StatusActive, AdapterType: gamedomain.AdapterZeeOneV1, AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } _, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, GameID: "zeeone_unknown", AccessToken: "app_access_token_zeeone", }) if err == nil || !strings.Contains(xerr.MessageOf(err), "zeeone launch url is required") { t.Fatalf("LaunchGame must reject missing zeeone launch url, got %v", err) } } func TestLaunchGameBuildsBaishunURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "baishun_greedy", PlatformCode: "baishun", ProviderGameID: "1006", GameName: "Greedy", Status: gamedomain.StatusActive, Orientation: "portrait", }, PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://fallback.example.invalid/game.html", AdapterType: gamedomain.AdapterBaishunV1, AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh","default_lang":"2","token_ttl_seconds":86400,"game_mode":"3","scene_mode":1,"currency_icon":"https://cdn.example/coin.png","gsp":101,"game_urls":{"1006":"https://game-cn-test.jieyou.shop/game-packages/greedy/index.html"}}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "baishun_greedy", RoomID: "room_1", AccessToken: "app_access_token_baishun", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.ExpiresAtMS != 1700086400000 { t.Fatalf("baishun launch must use adapter token ttl, got %+v", result) } parsed, err := url.Parse(result.LaunchURL) if err != nil { t.Fatalf("parse baishun launch url failed: %v", err) } if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://game-cn-test.jieyou.shop/game-packages/greedy/index.html" { t.Fatalf("baishun launch url must use per-game configured url: %s", result.LaunchURL) } query := parsed.Query() expected := map[string]string{ "appChannel": "mesh", "appId": "21397507", "userId": "42", "code": "app_access_token_baishun", "roomId": "room_1", "gameMode": "3", "language": "2", "sceneMode": "1", "isDataByUrl": "1", "bridge_keys": "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl", "currencyIcon": "https://cdn.example/coin.png", "gsp": "101", } for key, want := range expected { if got := query.Get(key); got != want { t.Fatalf("baishun launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL) } } } func TestLaunchGameBuildsVivaGamesURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "vivagames_2", PlatformCode: "vivagames", ProviderGameID: "2", GameName: "Viva Demo", Status: gamedomain.StatusActive, Orientation: "portrait", }, PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://fallback.example.invalid/game.html", AdapterType: gamedomain.AdapterVivaGamesV1, AdapterConfigJSON: `{ "app_id":1012127974, "default_lang":"en-US", "token_ttl_seconds":86400, "uid_mode":"display_user_id", "amount_rate":1, "bgm_switch":0, "gsp":101, "currency_icon":"https://cdn.example/coin.png", "bridge_script_url":"https://cdn.example/game-bridge/voice-room-game-bridge.v1.js", "bridge_script_version":"20260608.1", "bridge_script_sha256":"78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13", "game_urls":{"2":"https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html?game_id=2&isShow=1"} }`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "vivagames_2", RoomID: "room_1", AccessToken: "app_access_token_viva", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.ExpiresAtMS != 1700086400000 { t.Fatalf("vivagames launch must use adapter token ttl, got %+v", result) } parsed, err := url.Parse(result.LaunchURL) if err != nil { t.Fatalf("parse vivagames launch url failed: %v", err) } if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html" { t.Fatalf("vivagames launch url must use per-game configured url: %s", result.LaunchURL) } query := parsed.Query() expected := map[string]string{ "appId": "1012127974", "userId": "420001", "code": "app_access_token_viva", "gameId": "2", "metadata": `{"room_id":"room_1"}`, "language": "en-US", "coinType": "0", "amountRate": "1", "bgmSwitch": "0", "gsp": "101", "currencyIcon": "https://cdn.example/coin.png", "gameConfig": `{"currencyIcon":"https://cdn.example/coin.png"}`, "bridge_keys": "appId,userId,code,metadata,language,coinType,amountRate,bgmSwitch,gsp,currencyIcon,gameConfig,gameId,bridge_script_url,bridge_script_version,bridge_script_sha256", "bridge_script_url": "https://cdn.example/game-bridge/voice-room-game-bridge.v1.js", "bridgeScriptUrl": "https://cdn.example/game-bridge/voice-room-game-bridge.v1.js", "bridge_script_version": "20260608.1", "bridgeScriptVersion": "20260608.1", "bridge_script_sha256": "78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13", "bridgeScriptSha256": "78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13", } for key, want := range expected { if got := query.Get(key); got != want { t.Fatalf("vivagames launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL) } } } func TestLaunchGameBuildsReyouURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "reyou_101", PlatformCode: "reyou", ProviderGameID: "101", GameName: "Reyou Demo", Status: gamedomain.StatusActive, Orientation: "portrait", }, PlatformStatus: gamedomain.StatusActive, APIBaseURL: "https://fallback.example.invalid/game.html", AdapterType: gamedomain.AdapterReyouV1, AdapterConfigJSON: `{ "default_lang":"en", "token_ttl_seconds":86400, "uid_mode":"display_user_id", "bridge_script_url":"https://cdn.example/game-bridge/voice-room-game-bridge.v1.js", "bridge_script_version":"20260608.1", "bridge_script_sha256":"78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13", "game_urls":{"101":"https://games.reyou.example/test/box/half/?game_id=101"} }`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "reyou_101", RoomID: "room_1", AccessToken: "app_access_token_reyou", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.ExpiresAtMS != 1700086400000 { t.Fatalf("reyou launch must use adapter token ttl, got %+v", result) } parsed, err := url.Parse(result.LaunchURL) if err != nil { t.Fatalf("parse reyou launch url failed: %v", err) } if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://games.reyou.example/test/box/half/" { t.Fatalf("reyou launch url must use per-game configured url: %s", result.LaunchURL) } expected := map[string]string{ "game_id": "101", "uid": "420001", "token": "app_access_token_reyou", "lang": "en", "bridge_script_url": "https://cdn.example/game-bridge/voice-room-game-bridge.v1.js", "bridge_script_version": "20260608.1", "bridge_script_sha256": "78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13", } for key, want := range expected { if got := parsed.Query().Get(key); got != want { t.Fatalf("reyou launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL) } } } func TestLaunchGameRequiresBaishunLaunchURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "baishun_unknown", PlatformCode: "baishun", ProviderGameID: "9999", GameName: "Unknown Baishun Game", Status: gamedomain.StatusActive, }, PlatformStatus: gamedomain.StatusActive, AdapterType: gamedomain.AdapterBaishunV1, AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh"}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } _, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, GameID: "baishun_unknown", AccessToken: "app_access_token_baishun", }) if err == nil || !strings.Contains(xerr.MessageOf(err), "baishun launch url is required") { t.Fatalf("LaunchGame must reject missing baishun launch url, got %v", err) } } func TestLaunchGameAllowsLeaderCCWithoutLaunchURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "lingxian_rocket_001", PlatformCode: "lingxian", ProviderGameID: "101", GameName: "Rocket", Status: gamedomain.StatusActive, }, PlatformStatus: gamedomain.StatusActive, AdapterType: gamedomain.AdapterLeaderCCV1, AdapterConfigJSON: `{"token_ttl_seconds":86400}`, }, } svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } result, err := svc.LaunchGame(context.Background(), LaunchCommand{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "lingxian_rocket_001", AccessToken: "app_access_token_lingxian", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.LaunchURL != "" || repo.session.LaunchTokenHash != stableHash("app_access_token_lingxian") { t.Fatalf("leadercc without launch url should only create session: result=%+v session=%+v", result, repo.session) } } func TestHandleLeaderCCUserInfoValidatesSignAndToken(t *testing.T) { key := "leadercc-test-key" token := "gt_lingxian_token" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "lingxian", AdapterType: gamedomain.AdapterLeaderCCV1, CallbackSecretCiphertext: key, CallbackIPWhitelist: []string{"3.1.174.194"}, AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_lingxian", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "lingxian", GameID: "lingxian_rocket_001", ProviderGameID: "101", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1680} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } sign := leaderccTestSign(key, "101", "420001", token, "room_1") raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-user", AppCode: "lalu"}, PlatformCode: "lingxian", Operation: "userinfo", RawBody: []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","roomId":"room_1","sign":"` + sign + `"}`), RemoteAddr: "203.0.113.10:443", }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1680`) { t.Fatalf("leadercc user response mismatch: %s", raw) } } func TestHandleLeaderCCUserInfoAcceptsAppAccessTokenWithoutLaunchSession(t *testing.T) { key := "leadercc-test-key" token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600) repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "lingxian", AdapterType: gamedomain.AdapterLeaderCCV1, CallbackSecretCiphertext: key, AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, }, } wallet := &fakeWallet{balanceAfter: 1680} user := &fakeUser{} svc := New(Config{}, repo, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } sign := leaderccTestSign(key, "101", "420001", token, "room_1") raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-app-token-user", AppCode: "lalu"}, PlatformCode: "lingxian", Operation: "userinfo", RawBody: []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","roomId":"room_1","sign":"` + sign + `"}`), }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1680`) { t.Fatalf("leadercc app-token user response mismatch: %s", raw) } if user.lastGet.GetUserId() != 42 { t.Fatalf("user request must use token user_id, got %+v", user.lastGet) } } func TestHandleLeaderCCChangeCoinAndRepairOrder(t *testing.T) { key := "leadercc-test-key" token := "gt_lingxian_token" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "lingxian", AdapterType: gamedomain.AdapterLeaderCCV1, CallbackSecretCiphertext: key, AdapterConfigJSON: `{"uid_mode":"user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_lingxian", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "lingxian", GameID: "lingxian_rocket_001", ProviderGameID: "101", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1560} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } changeSign := leaderccTestSign(key, "order_1", "101", "round_1", "42", "120", "1", "2", token, "", "room_1") raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-change", AppCode: "lalu"}, PlatformCode: "lingxian", Operation: "change_coin", RawBody: []byte(`{"orderId":"order_1","gameId":"101","roundId":"round_1","uid":"42","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1560`) { t.Fatalf("leadercc change response mismatch: %s", raw) } if wallet.lastApply.GetCommandId() != "game:lingxian:order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 { t.Fatalf("wallet command mismatch: %+v", wallet.lastApply) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-change-replay", AppCode: "lalu"}, PlatformCode: "lingxian", Operation: "change_coin", RawBody: []byte(`{"orderId":"order_1","gameId":"101","roundId":"round_1","uid":"42","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("duplicate HandleCallback failed: %v", err) } if !strings.Contains(string(raw), `"errorCode":10003`) || !strings.Contains(string(raw), `"code":10003`) || !strings.Contains(string(raw), `"coin":1560`) { t.Fatalf("leadercc duplicate change response mismatch: %s", raw) } repairSign := leaderccTestSign(key, "repair_1", "101", "round_2", "42", "50", "2", "", "room_1") raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-repair", AppCode: "lalu"}, PlatformCode: "lingxian", Operation: "repair_order", RawBody: []byte(`{"orderId":"repair_1","gameId":"101","roundId":"round_2","uid":"42","coin":50,"rewardType":2,"winId":"","roomId":"room_1","sign":"` + repairSign + `"}`), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if !strings.Contains(string(raw), `"errorCode":0`) { t.Fatalf("repair response mismatch: %s", raw) } if repo.repair.ProviderOrderID != "repair_1" || repo.repair.Reason != "leadercc_repair_order" { t.Fatalf("repair order mismatch: %+v", repo.repair) } } func TestHandleLeaderCCChangeCoinAcceptsAppAccessTokenAndTokenOnlyProbe(t *testing.T) { key := "leadercc-test-key" token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600) repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "lingxian", AdapterType: gamedomain.AdapterLeaderCCV1, CallbackSecretCiphertext: key, AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, }, } wallet := &fakeWallet{balanceAfter: 1560} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } probeRaw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-token-probe", AppCode: "lalu"}, PlatformCode: "lingxian", Operation: "change_coin", RawBody: []byte(`{"token":"` + token + `"}`), }) if err != nil { t.Fatalf("token-only probe failed: %v", err) } if !strings.Contains(string(probeRaw), `"code":0`) || wallet.lastApply != nil { t.Fatalf("token-only probe must return ok without applying wallet: raw=%s wallet=%+v", probeRaw, wallet.lastApply) } changeSign := leaderccTestSign(key, "order_app_token_1", "101", "round_1", "420001", "120", "1", "2", token, "", "room_1") raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-app-token-change", AppCode: "lalu"}, PlatformCode: "lingxian", Operation: "change_coin", RawBody: []byte(`{"orderId":"order_app_token_1","gameId":"101","roundId":"round_1","uid":"420001","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`), }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"coin":1560`) { t.Fatalf("leadercc app-token change response mismatch: %s", raw) } if wallet.lastApply.GetUserId() != 42 || wallet.lastApply.GetGameId() != "101" || wallet.lastApply.GetCommandId() != "game:lingxian:order_app_token_1" { t.Fatalf("wallet command must use token user and request game: %+v", wallet.lastApply) } } func TestHandleYomiGetTokenAndUserInfo(t *testing.T) { secret := "kEdOjdxgOUCJjG1L6kl3fYEIgMFxQOza" token := "app_access_token_yomi" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "yomi", AdapterType: gamedomain.AdapterYomiV4, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_yomi", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "yomi", GameID: "yomi_fish_001", ProviderGameID: "33", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1880} user := &fakeUser{} svc := New(Config{}, repo, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } getTokenBody := encryptYomiTestBody(t, secret, `{"gameUid":33,"lang":"en","platAuthCode":"app_access_token_yomi","platUserId":"420001","platPayload":"","platRoomId":"room_1"}`) raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-yomi-gettoken", AppCode: "lalu"}, PlatformCode: "yomi", Operation: "gettoken", RawBody: getTokenBody, RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback gettoken failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platToken":"app_access_token_yomi"`) || !strings.Contains(string(raw), `"balance":1880`) { t.Fatalf("yomi gettoken response mismatch: %s", raw) } if user.lastGet.GetUserId() != 42 { t.Fatalf("yomi gettoken must query token user, got %+v", user.lastGet) } userInfoBody := encryptYomiTestBody(t, secret, `{"platUserId":"420001","platToken":"app_access_token_yomi","platPayload":"","platRoomId":"room_1"}`) raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-yomi-userinfo", AppCode: "lalu"}, PlatformCode: "yomi", Operation: "userinfo", RawBody: userInfoBody, RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback userinfo failed: %v", err) } if !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platUserId":"420001"`) || !strings.Contains(string(raw), `"avatar":"https://cdn.example/avatar.png"`) { t.Fatalf("yomi userinfo response mismatch: %s", raw) } } func TestHandleYomiChangeBalanceDecryptsAndAppliesWallet(t *testing.T) { secret := "12345678901234567890123456789012" token := "gt_test_token" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "yomi", AdapterType: gamedomain.AdapterYomiV4, CallbackSecretCiphertext: secret, CallbackIPWhitelist: []string{"3.1.174.194"}, AdapterConfigJSON: `{"uid_mode":"user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_test", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "yomi", GameID: "yomi_fish_001", ProviderGameID: "33", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 880} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } rawBody := encryptYomiTestBody(t, secret, `{"requestId":9001,"platUserId":"42","platToken":"gt_test_token","gold":120,"gameUid":33,"gameLevelUid":0,"reasonType":1,"gameRoundId":88,"timestamp":1700000000,"platPayload":"","platRoomId":"room_1"}`) raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-yomi-change", AppCode: "lalu"}, PlatformCode: "yomi", Operation: "change_balance", RawBody: rawBody, RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"balance":880`) { t.Fatalf("yomi change response mismatch: %s", raw) } if wallet.lastApply.GetCommandId() != "game:yomi:9001" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 { t.Fatalf("wallet command mismatch: %+v", wallet.lastApply) } if repo.order.ProviderOrderID != "9001" || repo.order.ProviderRoundID != "88" || repo.order.GameID != "yomi_fish_001" { t.Fatalf("game order mismatch: %+v", repo.order) } } func TestHandleYomiRepairOrderOnlyLogs(t *testing.T) { secret := "12345678901234567890123456789012" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "yomi", AdapterType: gamedomain.AdapterYomiV4, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{}`, }, } wallet := &fakeWallet{balanceAfter: 880} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } rawBody := encryptYomiTestBody(t, secret, `{"requestId":9002,"platUserId":"42","gold":50,"gameUid":33,"gameRoundId":89,"rewardType":2,"winId":"","platRoomId":"room_1"}`) raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-yomi-repair", AppCode: "lalu"}, PlatformCode: "yomi", Operation: "repair_order", RawBody: rawBody, RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if !strings.Contains(string(raw), `"code":200`) { t.Fatalf("repair response mismatch: %s", raw) } if repo.repair.ProviderOrderID != "9002" || repo.repair.Status != gamedomain.RepairStatusLogged { t.Fatalf("repair order mismatch: %+v", repo.repair) } if wallet.lastApply != nil { t.Fatalf("repair order must not apply wallet change: %+v", wallet.lastApply) } } func TestHandleZeeOneUserInfoChangeBalanceAndUpdateSession(t *testing.T) { secret := "zeeone-test-secret" token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600) repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "zeeone", AdapterType: gamedomain.AdapterZeeOneV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`, }, } wallet := &fakeWallet{balanceAfter: 1880} user := &fakeUser{} svc := New(Config{}, repo, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } userBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","game_id":1021,"extra":"{\"vip\":1}"}` raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-user", AppCode: "lalu"}, PlatformCode: "zeeone", Operation: "get_user_info", RawBody: zeeoneTestEnvelope(t, secret, userBody, 1700000000000), }) if err != nil { t.Fatalf("HandleCallback userinfo failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"balance":1880`) || !strings.Contains(string(raw), `"sessionId"`) { t.Fatalf("zeeone userinfo response mismatch: %s", raw) } if user.lastGet.GetUserId() != 42 { t.Fatalf("zeeone userinfo must query token user, got %+v", user.lastGet) } changeBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","currency_diff":-120,"diff_msg":"bet","game_id":1021,"room_id":"01","change_time_at":1700000000,"order_id":"zee_order_1","extend":"round_1"}` raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-change", AppCode: "lalu"}, PlatformCode: "zeeone", Operation: "change_balance", RawBody: zeeoneTestEnvelope(t, secret, changeBody, 1700000000000), }) if err != nil { t.Fatalf("HandleCallback change failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"currency_balance":1880`) { t.Fatalf("zeeone change response mismatch: %s", raw) } if wallet.lastApply.GetUserId() != 42 || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "1021" { t.Fatalf("zeeone wallet command mismatch: %+v", wallet.lastApply) } updateBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","game_id":1021}` raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-update-session", AppCode: "lalu"}, PlatformCode: "zeeone", Operation: "update_session", RawBody: zeeoneTestEnvelope(t, secret, updateBody, 1700000000000), }) if err != nil { t.Fatalf("HandleCallback update_session failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expireDate":1700003600000`) { t.Fatalf("zeeone update_session response mismatch: %s", raw) } } func TestHandleZeeOneAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) { secret := "zeeone-test-secret" token := leaderccTestAccessToken(t, "lalu", 123456, "123456", 1700003600) repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "zeeone", AdapterType: gamedomain.AdapterZeeOneV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`, }, // 同一个 App access token 启动过其他 ZeeOne 游戏时,旧启动会话会留下同 hash 但不同 provider_game_id。 // 回调必须以 JWT 用户身份为准,不能被旧会话的 game_id 拦截成 sessionId invalid。 session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "old_launch_session", UserID: 123456, DisplayUserID: "123456", ProviderGameID: "1021", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700003600000, }, } wallet := &fakeWallet{balanceAfter: 900} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } changeBody := `{"appId":"M307715_P2032","userId":"123456","sessionId":"` + token + `","currency_diff":-100,"diff_msg":"bet","game_id":1032,"room_id":"6","change_time_at":1700000000,"order_id":"zee_order_stale_session"}` raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-stale-session", AppCode: "lalu"}, PlatformCode: "zeeone", Operation: "change_balance", RawBody: zeeoneTestEnvelope(t, secret, changeBody, 1700000000000), }) if err != nil { t.Fatalf("HandleCallback change failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || strings.Contains(string(raw), "sessionId invalid") { t.Fatalf("zeeone access token should ignore stale launch session game id: %s", raw) } if wallet.lastApply.GetUserId() != 123456 || wallet.lastApply.GetGameId() != "1032" { t.Fatalf("zeeone wallet command must use JWT user and callback game id, got %+v", wallet.lastApply) } } func TestHandleZeeOneAllowsDebitAndCreditWithSameProviderOrderID(t *testing.T) { secret := "zeeone-test-secret" token := leaderccTestAccessToken(t, "lalu", 123456, "123456", 1700003600) repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "zeeone", AdapterType: gamedomain.AdapterZeeOneV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`, }, } wallet := &fakeWallet{balanceAfter: 900} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } base := `{"appId":"M307715_P2032","userId":"123456","sessionId":"` + token + `","diff_msg":"settle","game_id":1021,"room_id":"6","change_time_at":1700000000,"order_id":"zee_round_order_1"` for _, item := range []struct { name string diff string wantOp string wantID string wantCoins int64 }{ {name: "debit", diff: "-500", wantOp: "debit", wantID: "game:zeeone:zee_round_order_1:debit", wantCoins: 500}, {name: "credit", diff: "750", wantOp: "credit", wantID: "game:zeeone:zee_round_order_1:credit", wantCoins: 750}, } { raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-" + item.name, AppCode: "lalu"}, PlatformCode: "zeeone", Operation: "change_balance", RawBody: zeeoneTestEnvelope(t, secret, base+`,"currency_diff":`+item.diff+`}`, 1700000000000), }) if err != nil { t.Fatalf("HandleCallback %s failed: %v", item.name, err) } if !strings.Contains(string(raw), `"code":0`) { t.Fatalf("zeeone %s response mismatch: %s", item.name, raw) } if wallet.lastApply.GetCommandId() != item.wantID || wallet.lastApply.GetOpType() != item.wantOp || wallet.lastApply.GetCoinAmount() != item.wantCoins { t.Fatalf("zeeone %s wallet command mismatch: %+v", item.name, wallet.lastApply) } } if wallet.applyCount != 2 { t.Fatalf("debit and credit should both reach wallet, got %d applies", wallet.applyCount) } } func TestHandleBaishunSSTokenUserInfoUpdateAndChangeBalance(t *testing.T) { secret := "baishun-test-app-key" token := "app_access_token_baishun" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "baishun", AdapterType: gamedomain.AdapterBaishunV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh","uid_mode":"display_user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_baishun", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "baishun", GameID: "baishun_greedy", ProviderGameID: "1006", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1880} user := &fakeUser{} svc := New(Config{}, repo, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-baishun-sstoken", AppCode: "lalu"}, PlatformCode: "baishun", Operation: "get_sstoken", RawBody: baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","code":"app_access_token_baishun"}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback get_sstoken failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"ss_token":"app_access_token_baishun"`) || !strings.Contains(string(raw), `"user_info"`) || !strings.Contains(string(raw), `"balance":1880`) { t.Fatalf("baishun get_sstoken response mismatch: %s", raw) } if user.lastGet.GetUserId() != 42 { t.Fatalf("baishun get_sstoken must query token user, got %+v", user.lastGet) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-baishun-userinfo", AppCode: "lalu"}, PlatformCode: "baishun", Operation: "get_user_info", RawBody: baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","ss_token":"app_access_token_baishun","client_ip":"110.86.1.130","game_id":1006}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback get_user_info failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"user_id":"420001"`) || !strings.Contains(string(raw), `"user_avatar":"https://cdn.example/avatar.png"`) { t.Fatalf("baishun userinfo response mismatch: %s", raw) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-baishun-update-sstoken", AppCode: "lalu"}, PlatformCode: "baishun", Operation: "update_sstoken", RawBody: baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","ss_token":"app_access_token_baishun","game_id":1006}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback update_sstoken failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expire_date":1700086400000`) { t.Fatalf("baishun update_sstoken response mismatch: %s", raw) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-baishun-change", AppCode: "lalu"}, PlatformCode: "baishun", Operation: "change_balance", RawBody: baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","ss_token":"app_access_token_baishun","currency_diff":-120,"diff_msg":"bet","game_id":1006,"room_id":"room_1","game_round_id":"round_1","change_time_at":1700000000,"order_id":"bs_order_1","extend":"{\"source\":\"test\"}"}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback change_balance failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"currency_balance":1880`) || !strings.Contains(string(raw), `"extend":"{\"source\":\"test\"}"`) { t.Fatalf("baishun change response mismatch: %s", raw) } if wallet.lastApply.GetCommandId() != "game:baishun:bs_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "baishun_greedy" { t.Fatalf("baishun wallet command mismatch: %+v", wallet.lastApply) } } func TestHandleVivaGamesTokenUserInfoUpdateAndChangeBalance(t *testing.T) { secret := "vivagames-test-app-key" token := "app_access_token_viva" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "vivagames", AdapterType: gamedomain.AdapterVivaGamesV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"app_id":1012127974,"uid_mode":"display_user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_viva", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "vivagames", GameID: "vivagames_2", ProviderGameID: "2", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1880} user := &fakeUser{} svc := New(Config{}, repo, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-viva-token", AppCode: "lalu"}, PlatformCode: "vivagames", Operation: "get-token", RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","code":"app_access_token_viva"}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback get-token failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"token":"app_access_token_viva"`) || !strings.Contains(string(raw), `"expire_at":1700086400000`) { t.Fatalf("vivagames get-token response mismatch: %s", raw) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-viva-userinfo", AppCode: "lalu"}, PlatformCode: "vivagames", Operation: "get-user-info", RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva","client_ip":"110.86.1.130","game_id":2}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback get-user-info failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"user_id":"420001"`) || !strings.Contains(string(raw), `"avatar_url":"https://cdn.example/avatar.png"`) || !strings.Contains(string(raw), `"balance":1880`) { t.Fatalf("vivagames userinfo response mismatch: %s", raw) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-viva-update", AppCode: "lalu"}, PlatformCode: "vivagames", Operation: "update-token", RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva"}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback update-token failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expire_at":1700086400000`) { t.Fatalf("vivagames update-token response mismatch: %s", raw) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-viva-change", AppCode: "lalu"}, PlatformCode: "vivagames", Operation: "change-balance", RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva","order_id":"viva_order_1","game_round_id":"round_1","game_id":2,"total_bet":120,"change_amount":-120,"change_reason":"bet","change_time_at":1700000000,"metadata":"{\"room_id\":\"room_1\"}"}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback change-balance failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"balance":1880`) { t.Fatalf("vivagames change response mismatch: %s", raw) } if wallet.lastApply.GetCommandId() != "game:vivagames:viva_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "vivagames_2" { t.Fatalf("vivagames wallet command mismatch: %+v", wallet.lastApply) } } func TestHandleReyouUserInfoAndUpdateBalance(t *testing.T) { secret := "reyou-test-key" token := "app_access_token_reyou" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "reyou", AdapterType: gamedomain.AdapterReyouV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_reyou", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "reyou", GameID: "reyou_101", ProviderGameID: "101", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1880} user := &fakeUser{} svc := New(Config{}, repo, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } userInfoSign := reyouTestSign(secret, "101", "420001", token) raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-reyou-userinfo", AppCode: "lalu"}, PlatformCode: "reyou", Operation: "getUserInfo", RawBody: []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","sign":"` + userInfoSign + `"}`), }) if err != nil { t.Fatalf("HandleCallback getUserInfo failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"uid":"420001"`) || !strings.Contains(string(raw), `"coin":1880`) || !strings.Contains(string(raw), `"vipLevel":0`) { t.Fatalf("reyou userinfo response mismatch: %s", raw) } if user.lastGet.GetUserId() != 42 { t.Fatalf("user request mismatch: %+v", user.lastGet) } changeSign := reyouTestSign(secret, "reyou_order_1", "101", "round_1", "420001", "120", "1", token) changeBody := `{"orderId":"reyou_order_1","gameId":"101","roundId":"round_1","uid":"420001","coin":120,"type":1,"token":"` + token + `","sign":"` + changeSign + `"}` raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-reyou-change", AppCode: "lalu"}, PlatformCode: "reyou", Operation: "updateBalance", RawBody: []byte(changeBody), }) if err != nil { t.Fatalf("HandleCallback updateBalance failed: %v", err) } if !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1880`) || !strings.Contains(string(raw), `"responseId":"reyou_`) { t.Fatalf("reyou change response mismatch: %s", raw) } if wallet.lastApply.GetCommandId() != "game:reyou:reyou_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "reyou_101" { t.Fatalf("reyou wallet command mismatch: %+v", wallet.lastApply) } if wallet.applyCount != 1 { t.Fatalf("wallet apply count = %d, want 1", wallet.applyCount) } raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-reyou-change-replay", AppCode: "lalu"}, PlatformCode: "reyou", Operation: "updateBalance", RawBody: []byte(changeBody), }) if err != nil { t.Fatalf("duplicate updateBalance failed: %v", err) } if !strings.Contains(string(raw), `"errorCode":3001`) || !strings.Contains(string(raw), `"coin":1880`) { t.Fatalf("reyou duplicate response mismatch: %s", raw) } if wallet.applyCount != 1 { t.Fatalf("duplicate order must not apply wallet again, count=%d", wallet.applyCount) } } func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) { secret := "baishun-test-app-key" userID := int64(312900923573673984) token := leaderccTestAccessToken(t, "lalu", userID, "163004", 1700003600) repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "baishun", AdapterType: gamedomain.AdapterBaishunV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"app_id":7482496867,"app_channel":"bobi","uid_mode":"display_user_id"}`, }, // 同一个 App access token 会被百顺 H5 作为 ss_token 复用;旧启动会话只说明曾经打开过别的游戏。 // 回调验权必须以 JWT 里的当前用户身份为准,否则 user_info 会被旧 provider_game_id 拦成 token invalid。 session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "old_baishun_launch_session", UserID: userID, DisplayUserID: "163004", PlatformCode: "baishun", ProviderGameID: "1006", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1880} user := &fakeUser{} svc := New(Config{}, repo, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-baishun-stale-session", AppCode: "lalu"}, PlatformCode: "baishun", Operation: "get_user_info", RawBody: baishunTestBody(t, secret, `{"app_id":7482496867,"user_id":"163004","ss_token":"`+token+`","provider_name":"bobi","client_ip":"104.28.244.150","game_id":1043,"currency_type":0}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback get_user_info failed: %v", err) } if !strings.Contains(string(raw), `"code":0`) || strings.Contains(string(raw), "token invalid") || !strings.Contains(string(raw), `"user_id":"163004"`) { t.Fatalf("baishun access token should ignore stale launch session game id: %s", raw) } if user.lastGet.GetUserId() != userID { t.Fatalf("baishun userinfo must query JWT user, got %+v", user.lastGet) } } func TestHandleBaishunGetSSTokenReturnsTokenInvalidWhenUserMissing(t *testing.T) { secret := "baishun-test-app-key" token := "app_access_token_baishun" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: "baishun", AdapterType: gamedomain.AdapterBaishunV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh","uid_mode":"display_user_id"}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", SessionID: "game_sess_baishun_missing_user", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: "baishun", GameID: "baishun_greedy", ProviderGameID: "1006", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } svc := New(Config{}, repo, &fakeWallet{}, &fakeUser{err: xerr.New(xerr.NotFound, "user not found")}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-baishun-missing-user", AppCode: "lalu"}, PlatformCode: "baishun", Operation: "get_sstoken", RawBody: baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","code":"app_access_token_baishun"}`, 1700000000), RemoteAddr: "3.1.174.194:443", }) if err != nil { t.Fatalf("HandleCallback get_sstoken failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"code":1001`) || !strings.Contains(string(raw), `"message":"token invalid"`) { t.Fatalf("baishun missing user response mismatch: %s", raw) } if strings.Contains(string(raw), `"code":1002`) || strings.Contains(string(raw), "read data failed") { t.Fatalf("baishun missing user must not look like read data failure: %s", raw) } } func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}}, } wallet := &fakeWallet{balanceAfter: 880} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } req := &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-callback", AppCode: "lalu"}, PlatformCode: "demo", Operation: "change_coin", RawBody: []byte(`{"user_id":42,"game_id":"demo_rocket_001","provider_order_id":"porder_1","op_type":"debit","coin_amount":120,"room_id":"room_1"}`), } raw, contentType, err := svc.HandleCallback(context.Background(), req) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"balance_after":880`) { t.Fatalf("callback response mismatch: contentType=%s raw=%s", contentType, raw) } if wallet.lastApply.GetCommandId() != "game:demo:porder_1" || wallet.lastApply.GetCoinAmount() != 120 { t.Fatalf("wallet command mismatch: %+v", wallet.lastApply) } if repo.order.Status != gamedomain.OrderStatusSucceeded || repo.order.WalletTransactionID != "wtx-game-1" { t.Fatalf("order state mismatch: %+v", repo.order) } if len(repo.levelEvents) != 1 || repo.levelEvents[0].CoinAmount != 120 { t.Fatalf("level outbox event mismatch: %+v", repo.levelEvents) } raw, _, err = svc.HandleCallback(context.Background(), req) if err != nil { t.Fatalf("idempotent callback failed: %v", err) } if !strings.Contains(string(raw), `"idempotent_replay":true`) { t.Fatalf("idempotent response mismatch: %s", raw) } if len(repo.levelEvents) != 1 { t.Fatalf("idempotent replay should not duplicate level outbox events: %+v", repo.levelEvents) } } func TestHandleCallbackGetUserInfoUsesUserAndWallet(t *testing.T) { wallet := &fakeWallet{balanceAfter: 1680} user := &fakeUser{} svc := New(Config{}, &fakeRepository{}, wallet, user) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-user-info", AppCode: "lalu"}, PlatformCode: "demo", Operation: "get_user_info", RawBody: []byte(`{"user_id":42}`), }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } if contentType != "application/json" || !strings.Contains(string(raw), `"display_user_id":"420001"`) || !strings.Contains(string(raw), `"balance":1680`) { t.Fatalf("user info response mismatch: %s", raw) } if user.lastGet.GetUserId() != 42 { t.Fatalf("user request mismatch: %+v", user.lastGet) } } func TestProcessLevelEventOutboxBatchRelaysGameSpend(t *testing.T) { repo := &fakeRepository{levelEvents: []gamedomain.LevelEventOutbox{{ AppCode: "lalu", EventID: "game_level:order_1", OrderID: "order_1", UserID: 42, GameID: "demo_rocket_001", ProviderOrderID: "porder_1", ProviderRoundID: "round_1", CoinAmount: 120, WalletTransactionID: "wtx-game-1", PayloadJSON: `{"game_id":"demo_rocket_001"}`, OccurredAtMS: 1700000000000, }}} activity := &fakeActivity{status: "consumed"} svc := New(Config{}, repo, &fakeWallet{}, &fakeUser{}, activity) svc.now = func() time.Time { return time.UnixMilli(1700000001000) } claimed, processed, success, failure, hasMore, err := svc.ProcessLevelEventOutboxBatch(context.Background(), "run_1", "worker_1", 100, 30*time.Second) if err != nil { t.Fatalf("ProcessLevelEventOutboxBatch failed: %v", err) } if claimed != 1 || processed != 1 || success != 1 || failure != 0 || hasMore { t.Fatalf("batch result mismatch: claimed=%d processed=%d success=%d failure=%d hasMore=%v", claimed, processed, success, failure, hasMore) } if activity.last.GetTrack() != "game" || activity.last.GetMetricType() != "game_spend_coin" || activity.last.GetValueDelta() != 120 { t.Fatalf("activity relay request mismatch: %+v", activity.last) } if len(repo.deliveredEvents) != 1 || repo.deliveredEvents[0] != "game_level:order_1" { t.Fatalf("delivered state mismatch: %+v", repo.deliveredEvents) } } type fakeRepository struct { launchable gamedomain.LaunchableGame platform gamedomain.Platform session gamedomain.LaunchSession order gamedomain.GameOrder orders []gamedomain.GameOrder repair gamedomain.RepairOrder levelEvents []gamedomain.LevelEventOutbox recentGames []gamedomain.AppGame lastRecentQuery ListRecentGamesQuery platforms []gamedomain.Platform lastListPlatformsAppCode string lastListPlatformsStatus string deliveredEvents []string failedEvents []string } func (f *fakeRepository) Ping(context.Context) error { return nil } func (f *fakeRepository) ListGames(context.Context, ListGamesQuery) ([]gamedomain.AppGame, error) { return []gamedomain.AppGame{{GameID: "demo_rocket_001"}}, nil } func (f *fakeRepository) ListRecentGames(_ context.Context, query ListRecentGamesQuery) ([]gamedomain.AppGame, error) { f.lastRecentQuery = query if f.recentGames != nil { return f.recentGames, nil } return []gamedomain.AppGame{{GameID: "demo_recent_001"}}, nil } func (f *fakeRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) { return f.launchable, nil } func (f *fakeRepository) GetPlatform(context.Context, string, string) (gamedomain.Platform, error) { return f.platform, nil } func (f *fakeRepository) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error { f.session = session return nil } func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, token string) (gamedomain.LaunchSession, error) { if f.session.LaunchTokenHash == stableHash(token) { return f.session, nil } return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session token invalid") } func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil } func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) { for _, existing := range f.orders { if existing.OrderID == order.OrderID { f.order = existing return existing, true, nil } } if f.order.OrderID != "" && len(f.orders) == 0 { if f.order.OrderID == order.OrderID { return f.order, true, nil } f.orders = append(f.orders, f.order) } f.order = order f.orders = append(f.orders, order) return order, false, nil } func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error { f.order.OrderID = orderID f.order.Status = gamedomain.OrderStatusSucceeded f.order.WalletTransactionID = walletTransactionID f.order.WalletBalanceAfter = balanceAfter f.order.UpdatedAtMS = nowMs for index := range f.orders { if f.orders[index].OrderID == orderID { f.orders[index] = f.order break } } if strings.EqualFold(f.order.OpType, "debit") && f.order.CoinAmount > 0 && len(f.levelEvents) == 0 { f.levelEvents = append(f.levelEvents, gamedomain.LevelEventOutbox{ AppCode: f.order.AppCode, EventID: "game_level:" + f.order.OrderID, OrderID: f.order.OrderID, UserID: f.order.UserID, GameID: f.order.GameID, ProviderOrderID: f.order.ProviderOrderID, ProviderRoundID: f.order.ProviderRoundID, CoinAmount: f.order.CoinAmount, WalletTransactionID: walletTransactionID, OccurredAtMS: nowMs, CreatedAtMS: nowMs, UpdatedAtMS: nowMs, }) } return nil } func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMs int64) error { f.order.OrderID = orderID f.order.Status = status f.order.FailureCode = code f.order.FailureMessage = message f.order.UpdatedAtMS = nowMs for index := range f.orders { if f.orders[index].OrderID == orderID { f.orders[index] = f.order break } } return nil } func (f *fakeRepository) CreateRepairOrder(_ context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) { if f.repair.RepairID != "" { return f.repair, true, nil } f.repair = order return order, false, nil } func (f *fakeRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) { events := append([]gamedomain.LevelEventOutbox(nil), f.levelEvents...) f.levelEvents = nil return events, nil } func (f *fakeRepository) MarkLevelEventDelivered(_ context.Context, eventID string, _ int64) error { f.deliveredEvents = append(f.deliveredEvents, eventID) return nil } func (f *fakeRepository) MarkLevelEventFailed(_ context.Context, eventID string, _ string, _ int64, _ int64) error { f.failedEvents = append(f.failedEvents, eventID) return nil } func (f *fakeRepository) ListPlatforms(_ context.Context, appCode string, status string) ([]gamedomain.Platform, error) { f.lastListPlatformsAppCode = appCode f.lastListPlatformsStatus = status if f.platforms != nil { return f.platforms, nil } return nil, nil } func (f *fakeRepository) UpsertPlatform(context.Context, gamedomain.Platform) (gamedomain.Platform, error) { return gamedomain.Platform{}, nil } func (f *fakeRepository) ListCatalog(context.Context, ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) { return nil, "", nil } func (f *fakeRepository) UpsertCatalog(context.Context, gamedomain.CatalogItem) (gamedomain.CatalogItem, error) { return gamedomain.CatalogItem{}, nil } func (f *fakeRepository) SetGameStatus(context.Context, string, string, string, int64) (gamedomain.CatalogItem, error) { return gamedomain.CatalogItem{}, nil } func (f *fakeRepository) DeleteCatalog(context.Context, string, string) error { return nil } type fakeWallet struct { lastApply *walletv1.ApplyGameCoinChangeRequest applyCount int balanceAfter int64 } func (f *fakeWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { f.lastApply = req f.applyCount++ return &walletv1.ApplyGameCoinChangeResponse{WalletTransactionId: "wtx-game-1", BalanceAfter: f.balanceAfter}, nil } func (f *fakeWallet) GetBalances(context.Context, *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: f.balanceAfter}}}, nil } type fakeActivity struct { status string last *activityv1.ConsumeLevelEventRequest } func (f *fakeActivity) ConsumeLevelEvent(_ context.Context, req *activityv1.ConsumeLevelEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeLevelEventResponse, error) { f.last = req status := f.status if status == "" { status = "consumed" } return &activityv1.ConsumeLevelEventResponse{EventId: req.GetEventId(), Status: status}, nil } type fakeUser struct { lastGet *userv1.GetUserRequest err error } func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) { f.lastGet = req if f.err != nil { return nil, f.err } return &userv1.GetUserResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "420001", Username: "tester", Avatar: "https://cdn.example/avatar.png", Country: "AE", RegionId: 100, }}, nil } func encryptYomiTestBody(t *testing.T, secret string, plain string) []byte { t.Helper() block, err := aes.NewCipher([]byte(secret)) if err != nil { t.Fatalf("aes cipher failed: %v", err) } gcm, err := cipher.NewGCM(block) if err != nil { t.Fatalf("gcm failed: %v", err) } nonce := []byte("123456789012") payload := append([]byte{}, nonce...) payload = append(payload, gcm.Seal(nil, nonce, []byte(plain), nil)...) return []byte(base64.URLEncoding.EncodeToString(payload)) } func leaderccTestSign(key string, parts ...string) string { builder := strings.Builder{} for _, part := range parts { builder.WriteString(strings.TrimSpace(part)) } builder.WriteString(strings.TrimSpace(key)) sum := md5.Sum([]byte(builder.String())) return hex.EncodeToString(sum[:]) } func reyouTestSign(key string, parts ...string) string { builder := strings.Builder{} for _, part := range parts { builder.WriteString(strings.TrimSpace(part)) } builder.WriteString(strings.TrimSpace(key)) sum := md5.Sum([]byte(builder.String())) return strings.ToUpper(hex.EncodeToString(sum[:])) } func leaderccTestAccessToken(t *testing.T, app string, userID int64, displayUserID string, expiresAtSec int64) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "iss": "hyapp", "app_code": app, "sub": strconv.FormatInt(userID, 10), "user_id": strconv.FormatInt(userID, 10), "display_user_id": displayUserID, "default_display_user_id": displayUserID, "sid": "sess_test_app_token", "typ": "access", "iat": int64(1700000000), "exp": expiresAtSec, }) signed, err := token.SignedString([]byte("test-secret")) if err != nil { t.Fatalf("sign test access token: %v", err) } return signed } func zeeoneTestEnvelope(t *testing.T, secret string, body string, timestamp int64) []byte { t.Helper() nonce := "5f0eb04d7603a9d8" raw := nonce + body + strconv.FormatInt(timestamp, 10) + secret sum := md5.Sum([]byte(raw)) signature := hex.EncodeToString(sum[:]) encodedBody, err := json.Marshal(body) if err != nil { t.Fatalf("marshal reqbody: %v", err) } return []byte(`{"reqbody":` + string(encodedBody) + `,"signature":"` + signature + `","signature_nonce":"` + nonce + `","timestamp":` + strconv.FormatInt(timestamp, 10) + `}`) } func baishunTestBody(t *testing.T, secret string, body string, timestamp int64) []byte { t.Helper() nonceSum := md5.Sum([]byte(body)) nonce := hex.EncodeToString(nonceSum[:8]) raw := nonce + strings.TrimSpace(secret) + strconv.FormatInt(timestamp, 10) sum := md5.Sum([]byte(raw)) var payload map[string]any decoder := json.NewDecoder(strings.NewReader(body)) decoder.UseNumber() if err := decoder.Decode(&payload); err != nil { t.Fatalf("decode baishun test body: %v", err) } payload["signature_nonce"] = nonce payload["timestamp"] = timestamp payload["signature"] = hex.EncodeToString(sum[:]) encoded, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal baishun test body: %v", err) } return encoded } func vivaGamesTestBody(t *testing.T, secret string, body string, timestamp int64) []byte { t.Helper() var payload map[string]any decoder := json.NewDecoder(strings.NewReader(body)) decoder.UseNumber() if err := decoder.Decode(&payload); err != nil { t.Fatalf("decode vivagames test body: %v", err) } payload["timestamp"] = json.Number(strconv.FormatInt(timestamp, 10)) payload["sign"] = vivaGamesSign(payload, secret) encoded, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal vivagames test body: %v", err) } return encoded }