293 lines
11 KiB
Go
293 lines
11 KiB
Go
package game
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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 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
|
|
session gamedomain.LaunchSession
|
|
order gamedomain.GameOrder
|
|
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) 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
|
|
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
|
|
return 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
|
|
}
|
|
|
|
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 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
|
|
}
|