package game import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "net/url" "strconv" "testing" "time" gamev1 "hyapp.local/api/proto/game/v1" gamedomain "hyapp/services/game-service/internal/domain/game" ) func TestLaunchGameBuildsAMGURL(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "amg_1001", PlatformCode: gamedomain.PlatformCodeAMG, ProviderGameID: "1001", GameName: "AMG Test", Status: gamedomain.StatusActive, Orientation: "portrait", }, PlatformStatus: gamedomain.StatusActive, AdapterType: gamedomain.AdapterAMGV1, AdapterConfigJSON: `{ "app_key":"kzphamrv01", "default_lang":"en", "token_ttl_seconds":86400, "mini":1, "app_audio":1, "use_lang":1, "game_urls":{"1001":"https://dev.playamg.com/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: "amg_1001", RoomID: "room_1", AccessToken: "app_access_token_amg", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) } if result.ExpiresAtMS != 1700086400000 { t.Fatalf("AMG launch must use adapter token ttl, got %+v", result) } parsed, err := url.Parse(result.LaunchURL) if err != nil { t.Fatalf("parse AMG launch URL: %v", err) } if got := parsed.Scheme + "://" + parsed.Host + parsed.Path; got != "https://dev.playamg.com/greedy/index.html" { t.Fatalf("AMG launch base mismatch: %s", result.LaunchURL) } want := map[string]string{ "user_token": "app_access_token_amg", "app_key": "kzphamrv01", "room_id": "room_1", "mini": "1", "lang": "en", "app_audio": "1", "use_lang": "1", } for key, value := range want { if got := parsed.Query().Get(key); got != value { t.Fatalf("AMG query %s = %q, want %q URL=%s", key, got, value, result.LaunchURL) } } } func TestHandleAMGUserReturnsProfileAndWallet(t *testing.T) { token := "amg_user_token" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: gamedomain.PlatformCodeAMG, AdapterType: gamedomain.AdapterAMGV1, AdapterConfigJSON: `{"uid_mode":"display_user_id","coin_scale":1}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", PlatformCode: gamedomain.PlatformCodeAMG, GameID: "amg_1001", ProviderGameID: "1001", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 1200} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-amg-user"}, PlatformCode: gamedomain.PlatformCodeAMG, Operation: "user", Headers: map[string]string{"Authorization": token}, }) if err != nil { t.Fatalf("HandleCallback failed: %v", err) } var response struct { Code int `json:"code"` Data struct { UserID string `json:"userId"` Nickname string `json:"nickname"` AvatarURL string `json:"avatarUrl"` AvailableCoins json.Number `json:"availableCoins"` RoomID string `json:"roomId"` } `json:"data"` Message string `json:"msg"` } if err := json.Unmarshal(raw, &response); err != nil { t.Fatalf("decode AMG user response: %v body=%s", err, raw) } if response.Code != 0 || response.Message != "success" || response.Data.UserID != "420001" || response.Data.Nickname != "tester" || response.Data.AvatarURL == "" || response.Data.AvailableCoins.String() != "1200" || response.Data.RoomID != "room_1" { t.Fatalf("AMG user response mismatch: %+v body=%s", response, raw) } } func TestHandleAMGSettlementDecryptsAndIsIdempotentAcrossRandomCiphertexts(t *testing.T) { privateKey, secret := amgTestPrivateKey(t) token := "amg_settlement_token" repo := &fakeRepository{ platform: gamedomain.Platform{ PlatformCode: gamedomain.PlatformCodeAMG, AdapterType: gamedomain.AdapterAMGV1, CallbackSecretCiphertext: secret, AdapterConfigJSON: `{"coin_scale":1}`, }, session: gamedomain.LaunchSession{ AppCode: "lalu", UserID: 42, DisplayUserID: "420001", RoomID: "room_1", RegionID: 100, CountryID: 971, PlatformCode: gamedomain.PlatformCodeAMG, GameID: "amg_1001", ProviderGameID: "1001", LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: 1700086400000, }, } wallet := &fakeWallet{balanceAfter: 900} svc := New(Config{}, repo, wallet, &fakeUser{}) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } plain := []byte(`{"transactionId":"amg-order-1","coins":100,"type":1,"timestamp":1700000000000,"ext":"room_game","gameId":1001}`) for attempt := 0; attempt < 2; attempt++ { encrypted := amgTestEncrypt(t, &privateKey.PublicKey, plain) envelope, err := json.Marshal(map[string]string{"data": encrypted}) if err != nil { t.Fatalf("marshal envelope: %v", err) } raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-amg-settle-" + strconv.Itoa(attempt)}, PlatformCode: gamedomain.PlatformCodeAMG, Operation: "settlement", Headers: map[string]string{"Authorization": token}, RawBody: envelope, }) if err != nil { t.Fatalf("HandleCallback attempt %d failed: %v", attempt, err) } var response struct { Code int `json:"code"` Data struct { AvailableCoins json.Number `json:"availableCoins"` } `json:"data"` } if err := json.Unmarshal(raw, &response); err != nil || response.Code != 0 || response.Data.AvailableCoins.String() != "900" { t.Fatalf("AMG settlement response mismatch: err=%v response=%+v body=%s", err, response, raw) } } if wallet.applyCount != 1 || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 100 || wallet.lastApply.GetProviderOrderId() != "amg-order-1" { t.Fatalf("AMG wallet change mismatch: count=%d request=%+v", wallet.applyCount, wallet.lastApply) } wantHash := stableHash("amg-order-1|100|1|1700000000000|room_game|1001") if repo.order.RequestHash != wantHash || repo.order.Status != gamedomain.OrderStatusSucceeded { t.Fatalf("AMG order idempotency mismatch: %+v want_hash=%s", repo.order, wantHash) } } func amgTestPrivateKey(t *testing.T) (*rsa.PrivateKey, string) { t.Helper() privateKey, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { t.Fatalf("generate RSA key: %v", err) } der, err := x509.MarshalPKCS8PrivateKey(privateKey) if err != nil { t.Fatalf("marshal PKCS#8 key: %v", err) } return privateKey, base64.StdEncoding.EncodeToString(der) } func amgTestEncrypt(t *testing.T, publicKey *rsa.PublicKey, plain []byte) string { t.Helper() chunkSize := publicKey.Size() - 11 ciphertext := make([]byte, 0, ((len(plain)+chunkSize-1)/chunkSize)*publicKey.Size()) for start := 0; start < len(plain); start += chunkSize { end := start + chunkSize if end > len(plain) { end = len(plain) } chunk, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, plain[start:end]) if err != nil { t.Fatalf("encrypt AMG test chunk: %v", err) } ciphertext = append(ciphertext, chunk...) } return hex.EncodeToString(ciphertext) }