package game import ( "context" "strings" "testing" "time" 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 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) } 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) } } 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) } } type fakeRepository struct { launchable gamedomain.LaunchableGame session gamedomain.LaunchSession order gamedomain.GameOrder } 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) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error { f.session = session return nil } func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil } func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) { if f.order.OrderID != "" { return f.order, true, nil } f.order = 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 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 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 } type fakeWallet struct { lastApply *walletv1.ApplyGameCoinChangeRequest balanceAfter int64 } func (f *fakeWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { f.lastApply = req 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 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 }