2026-06-09 11:04:31 +08:00

1888 lines
76 KiB
Go

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
}