2026-07-14 20:23:12 +08:00

233 lines
7.8 KiB
Go

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)
}