2026-05-27 17:27:13 +08:00

1363 lines
54 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.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 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",
"bridge_keys": "appChannel,appId,code,gameMode,gsp,language,roomId,sceneMode",
"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 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 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
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) 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, string, string) ([]gamedomain.Platform, error) {
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
}
func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
f.lastGet = req
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 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
}