hyapp-server/tests/smoke/room_rps_real_im_flow_test.go
2026-06-11 13:33:44 +08:00

485 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package smoke_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
"hyapp/pkg/tencentim"
)
func TestRoomRPSLocalHTTPFlowWithRealTencentIM(t *testing.T) {
if os.Getenv("ROOM_RPS_REAL_IM_FLOW_TEST") != "1" {
t.Skip("set ROOM_RPS_REAL_IM_FLOW_TEST=1 to run the local room-rps HTTP flow with real Tencent IM")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
cfg := roomRPSRealIMConfigFromEnv(t)
imClient, err := tencentim.NewRESTClient(cfg)
if err != nil {
t.Fatalf("new real Tencent IM client failed: %v", err)
}
baseURL := strings.TrimRight(envOr("ROOM_RPS_BASE_URL", "http://127.0.0.1:13000/api/v1"), "/")
appCode := envOr("ROOM_RPS_APP_CODE", "lalu")
roomID := envOr("ROOM_RPS_ROOM_ID", fmt.Sprintf("room-rps-real-im-%d", time.Now().UnixMilli()))
imGroupID := envOr("ROOM_RPS_IM_GROUP_ID", roomID)
giftID := positiveInt64EnvOr(t, "ROOM_RPS_GIFT_ID", 10001)
httpClient := &http.Client{Timeout: 15 * time.Second}
initiator := quickCreateRoomRPSSmokeAccount(t, ctx, httpClient, baseURL, appCode, "room-rps-initiator")
challenger := quickCreateRoomRPSSmokeAccount(t, ctx, httpClient, baseURL, appCode, "room-rps-challenger")
if initiator.UserID == challenger.UserID {
t.Fatalf("quick-created users must be different: initiator=%+v challenger=%+v", initiator, challenger)
}
// 真实 IM 是这个 smoke 的硬前置:先确保房间群存在并导入两个账号,避免后续 HTTP 成功但 IM 配置其实不可用。
if err := imClient.EnsureGroup(ctx, tencentim.GroupSpec{
GroupID: imGroupID,
Name: "room rps real im smoke " + roomID,
Type: tencentim.DefaultGroupType,
ApplyJoinOption: "FreeAccess",
}); err != nil {
t.Fatalf("ensure real Tencent IM room group failed: %v", err)
}
if shouldCleanupRoomRPSIMGroup() {
if !strings.HasPrefix(imGroupID, "room-rps-real-im-") {
t.Fatalf("ROOM_RPS_CLEANUP_IM_GROUP=1 only allows group id with room-rps-real-im- prefix, got %q", imGroupID)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cleanupCancel()
if err := imClient.DestroyGroup(cleanupCtx, imGroupID); err != nil {
t.Errorf("destroy real Tencent IM room-rps smoke group failed: %v", err)
}
})
}
importRoomRPSIMAccount(t, ctx, imClient, initiator.UserID, initiator.Username)
importRoomRPSIMAccount(t, ctx, imClient, challenger.UserID, challenger.Username)
publishRoomRPSIMProbe(t, ctx, imClient, imGroupID, roomID)
// /im/usersig 是 App 真正登录腾讯 IM SDK 前要调用的 gateway 接口;这里校验本地 gateway 和真实腾讯 IM SDKAppID 使用同一套配置。
initiatorSig := requestRoomRPSUserSig(t, ctx, httpClient, baseURL, appCode, initiator.AccessToken, cfg.SDKAppID, initiator.UserID)
challengerSig := requestRoomRPSUserSig(t, ctx, httpClient, baseURL, appCode, challenger.AccessToken, cfg.SDKAppID, challenger.UserID)
if initiatorSig.UserID == challengerSig.UserID {
t.Fatalf("initiator and challenger must be different IM identifiers: %s", initiatorSig.UserID)
}
config := requestRoomRPSConfig(t, ctx, httpClient, baseURL, appCode, initiator.AccessToken)
if strings.ToLower(strings.TrimSpace(config.Config.Status)) != "active" {
t.Fatalf("room-rps config must be open by default, got=%+v", config.Config)
}
assertRoomRPSGiftConfigured(t, config, giftID)
listBefore := requestRoomRPSChallengeList(t, ctx, httpClient, baseURL, appCode, initiator.AccessToken, roomID)
t.Logf("room-rps list before create: count=%d next_cursor=%q", len(listBefore.Challenges), listBefore.NextCursor)
created := createRoomRPSChallenge(t, ctx, httpClient, baseURL, appCode, initiator.AccessToken, roomID, giftID, "rock")
if created.Challenge.ChallengeID == "" {
t.Fatalf("created room-rps challenge must contain challenge_id: %+v", created.Challenge)
}
if created.Challenge.Initiator.UserID != strconv.FormatInt(initiator.UserID, 10) {
t.Fatalf("created challenge initiator mismatch: %+v", created.Challenge.Initiator)
}
if strings.TrimSpace(created.Challenge.Initiator.User.Nickname) == "" || strings.TrimSpace(created.Challenge.Initiator.User.Avatar) == "" {
t.Fatalf("created challenge must include initiator nickname/avatar for IM UI: %+v", created.Challenge.Initiator.User)
}
accepted := acceptRoomRPSChallenge(t, ctx, httpClient, baseURL, appCode, challenger.AccessToken, created.Challenge.ChallengeID, "paper")
if accepted.Challenge.Challenger.UserID != strconv.FormatInt(challenger.UserID, 10) {
t.Fatalf("accepted challenge challenger mismatch: %+v", accepted.Challenge.Challenger)
}
if strings.TrimSpace(accepted.Challenge.Challenger.User.Nickname) == "" || strings.TrimSpace(accepted.Challenge.Challenger.User.Avatar) == "" {
t.Fatalf("accepted challenge must include challenger nickname/avatar for IM UI: %+v", accepted.Challenge.Challenger.User)
}
detail := getRoomRPSChallenge(t, ctx, httpClient, baseURL, appCode, challenger.AccessToken, created.Challenge.ChallengeID)
if detail.Challenge.ChallengeID != created.Challenge.ChallengeID {
t.Fatalf("detail challenge id mismatch: got=%q want=%q", detail.Challenge.ChallengeID, created.Challenge.ChallengeID)
}
if detail.Challenge.Initiator.User.Nickname == "" || detail.Challenge.Challenger.User.Nickname == "" {
t.Fatalf("detail must preserve both player display profiles: %+v", detail.Challenge)
}
t.Logf("room-rps real HTTP + IM smoke passed: base_url=%s room_id=%s im_group_id=%s challenge_id=%s", baseURL, roomID, imGroupID, created.Challenge.ChallengeID)
}
type roomRPSEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
}
type roomRPSUserSigData struct {
SDKAppID int64 `json:"sdk_app_id"`
UserID string `json:"user_id"`
UserSig string `json:"user_sig"`
ExpireAtMS int64 `json:"expire_at_ms"`
}
type roomRPSQuickCreateData struct {
UID string `json:"uid"`
Account string `json:"account"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
PasswordSet bool `json:"password_set"`
Token roomRPSAuthToken `json:"token"`
Username string `json:"-"`
UserID int64 `json:"-"`
}
type roomRPSAuthToken struct {
UserID string `json:"user_id"`
AccessToken string `json:"access_token"`
}
type roomRPSConfigData struct {
Config roomRPSConfig `json:"config"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomRPSConfig struct {
Status string `json:"status"`
ChallengeTimeoutMS int64 `json:"challenge_timeout_ms"`
RevealCountdownMS int64 `json:"reveal_countdown_ms"`
StakeGifts []roomRPSStakeGift `json:"stake_gifts"`
}
type roomRPSStakeGift struct {
GiftID string `json:"gift_id"`
GiftIDNumber int64 `json:"gift_id_number"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sort_order"`
}
type roomRPSChallengeListData struct {
Challenges []roomRPSChallenge `json:"challenges"`
NextCursor string `json:"next_cursor"`
PageSize int32 `json:"page_size"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomRPSChallengeData struct {
Challenge roomRPSChallenge `json:"challenge"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomRPSChallenge struct {
ChallengeID string `json:"challenge_id"`
RoomID string `json:"room_id"`
Status string `json:"status"`
StakeGiftID string `json:"stake_gift_id"`
StakeGiftIDNum int64 `json:"stake_gift_id_number"`
Initiator roomRPSPlayer `json:"initiator"`
Challenger roomRPSPlayer `json:"challenger"`
WinnerUserID string `json:"winner_user_id"`
SettlementStatus string `json:"settlement_status"`
}
type roomRPSPlayer struct {
UserID string `json:"user_id"`
UserIDNumber int64 `json:"user_id_number"`
Gesture string `json:"gesture"`
Result string `json:"result"`
User roomRPSDisplayUser `json:"user"`
}
type roomRPSDisplayUser struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
func quickCreateRoomRPSSmokeAccount(t *testing.T, ctx context.Context, client *http.Client, baseURL string, appCode string, role string) roomRPSQuickCreateData {
t.Helper()
nowMS := time.Now().UnixMilli()
username := fmt.Sprintf("%s-%d", role, nowMS)
body := map[string]any{
"password": "room-rps-smoke-pass-123",
"username": username,
"avatar": fmt.Sprintf("https://cdn.example.com/%s.png", role),
"gender": "unknown",
"country": "SG",
"device_id": fmt.Sprintf("%s-device-%d", role, nowMS),
"device": "codex-smoke",
"os_version": "local",
"birth": "2000-01-01",
"app_version": "1.0.0",
"build_number": "1",
"source": "room_rps_real_im_smoke",
"install_channel": "codex",
"platform": "android",
"language": "en",
"timezone": "Asia/Shanghai",
}
var data roomRPSQuickCreateData
doRoomRPSJSON(t, ctx, client, http.MethodPost, baseURL+"/auth/account/quick-create", appCode, "", body, &data)
data.Username = username
userID, err := strconv.ParseInt(strings.TrimSpace(data.Token.UserID), 10, 64)
if err != nil || userID <= 0 || strings.TrimSpace(data.AccessToken) == "" || !data.PasswordSet {
t.Fatalf("quick-create account response invalid: %+v", data)
}
data.UserID = userID
return data
}
func requestRoomRPSUserSig(t *testing.T, ctx context.Context, client *http.Client, baseURL, appCode, token string, sdkAppID int64, userID int64) roomRPSUserSigData {
t.Helper()
var data roomRPSUserSigData
doRoomRPSJSON(t, ctx, client, http.MethodGet, baseURL+"/im/usersig", appCode, token, nil, &data)
if data.SDKAppID != sdkAppID {
t.Fatalf("usersig sdk_app_id mismatch: got=%d want=%d data=%+v", data.SDKAppID, sdkAppID, data)
}
if data.UserID != strconv.FormatInt(userID, 10) || strings.TrimSpace(data.UserSig) == "" || data.ExpireAtMS <= time.Now().UnixMilli() {
t.Fatalf("usersig response mismatch for user %d: %+v", userID, data)
}
return data
}
func requestRoomRPSConfig(t *testing.T, ctx context.Context, client *http.Client, baseURL, appCode, token string) roomRPSConfigData {
t.Helper()
var data roomRPSConfigData
doRoomRPSJSON(t, ctx, client, http.MethodGet, baseURL+"/games/room-rps/config", appCode, token, nil, &data)
if data.Config.Status == "" {
t.Fatalf("room-rps config response is empty: %+v", data)
}
return data
}
func requestRoomRPSChallengeList(t *testing.T, ctx context.Context, client *http.Client, baseURL, appCode, token, roomID string) roomRPSChallengeListData {
t.Helper()
var data roomRPSChallengeListData
doRoomRPSJSON(t, ctx, client, http.MethodGet, baseURL+"/games/room-rps/challenges?room_id="+urlQueryEscape(roomID), appCode, token, nil, &data)
return data
}
func createRoomRPSChallenge(t *testing.T, ctx context.Context, client *http.Client, baseURL, appCode, token, roomID string, giftID int64, gesture string) roomRPSChallengeData {
t.Helper()
body := map[string]any{"room_id": roomID, "gift_id": giftID, "gesture": gesture}
var data roomRPSChallengeData
doRoomRPSJSON(t, ctx, client, http.MethodPost, baseURL+"/games/room-rps/challenges/create", appCode, token, body, &data)
return data
}
func acceptRoomRPSChallenge(t *testing.T, ctx context.Context, client *http.Client, baseURL, appCode, token, challengeID string, gesture string) roomRPSChallengeData {
t.Helper()
body := map[string]any{"gesture": gesture}
var data roomRPSChallengeData
doRoomRPSJSON(t, ctx, client, http.MethodPost, baseURL+"/games/room-rps/challenges/"+urlPathEscape(challengeID)+"/accept", appCode, token, body, &data)
return data
}
func getRoomRPSChallenge(t *testing.T, ctx context.Context, client *http.Client, baseURL, appCode, token, challengeID string) roomRPSChallengeData {
t.Helper()
var data roomRPSChallengeData
doRoomRPSJSON(t, ctx, client, http.MethodGet, baseURL+"/games/room-rps/challenges/"+urlPathEscape(challengeID), appCode, token, nil, &data)
return data
}
func doRoomRPSJSON(t *testing.T, ctx context.Context, client *http.Client, method string, url string, appCode string, token string, body any, out any) {
t.Helper()
var payload io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal %s %s body failed: %v", method, url, err)
}
payload = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, url, payload)
if err != nil {
t.Fatalf("build %s %s request failed: %v", method, url, err)
}
if strings.TrimSpace(token) != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("X-App-Code", appCode)
req.Header.Set("X-App-Language", "en")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("%s %s failed: %v", method, url, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s %s response failed: %v", method, url, err)
}
var envelope roomRPSEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode %s %s envelope failed: status=%d body=%s err=%v", method, url, resp.StatusCode, string(raw), err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != "OK" {
t.Fatalf("%s %s returned non-OK: status=%d code=%s message=%s request_id=%s body=%s", method, url, resp.StatusCode, envelope.Code, envelope.Message, envelope.RequestID, string(raw))
}
if out != nil {
if len(envelope.Data) == 0 {
t.Fatalf("%s %s returned empty data: request_id=%s", method, url, envelope.RequestID)
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
t.Fatalf("decode %s %s data failed: request_id=%s data=%s err=%v", method, url, envelope.RequestID, string(envelope.Data), err)
}
}
}
func assertRoomRPSGiftConfigured(t *testing.T, data roomRPSConfigData, giftID int64) {
t.Helper()
for _, gift := range data.Config.StakeGifts {
if gift.GiftIDNumber == giftID || gift.GiftID == strconv.FormatInt(giftID, 10) {
if !gift.Enabled {
t.Fatalf("ROOM_RPS_GIFT_ID is configured but disabled: %+v", gift)
}
return
}
}
t.Fatalf("ROOM_RPS_GIFT_ID=%d not found in room-rps config: %+v", giftID, data.Config.StakeGifts)
}
func publishRoomRPSIMProbe(t *testing.T, ctx context.Context, client *tencentim.RESTClient, groupID string, roomID string) {
t.Helper()
nowMS := time.Now().UTC().UnixMilli()
eventID := fmt.Sprintf("room_rps_real_im_probe:%d", nowMS)
payload, err := json.Marshal(map[string]any{
"event_id": eventID,
"event_type": "room_rps_real_im_probe",
"room_id": roomID,
"challenge_id": "",
"server_time_ms": nowMS,
"challenge": map[string]any{
"room_id": roomID,
"status": "probe",
},
})
if err != nil {
t.Fatalf("marshal real IM probe failed: %v", err)
}
// 这里真的调用腾讯云 IM REST 发送 TIMCustomElem成功表示本地配置、admin UserSig、群 ID 和消息 payload 都被腾讯服务端接受。
if err := client.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
GroupID: groupID,
EventID: eventID,
Desc: "room_rps_real_im_probe",
Ext: "room_rps",
PayloadJSON: payload,
}); err != nil {
t.Fatalf("publish real Tencent IM room-rps probe failed: %v", err)
}
}
func importRoomRPSIMAccount(t *testing.T, ctx context.Context, client *tencentim.RESTClient, userID int64, nick string) {
t.Helper()
if err := client.ImportAccount(ctx, tencentim.AccountProfile{
UserID: userID,
Nick: nick,
FaceURL: "https://cdn.example.com/room-rps-smoke.png",
}); err != nil {
t.Fatalf("import real Tencent IM account user_id=%d failed: %v", userID, err)
}
}
func roomRPSRealIMConfigFromEnv(t *testing.T) tencentim.RESTConfig {
t.Helper()
sdkAppID := requiredPositiveInt64Env(t, "TENCENT_IM_SDK_APP_ID")
ttl := time.Hour
if rawTTL := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); rawTTL != "" {
parsed, err := time.ParseDuration(rawTTL)
if err != nil || parsed <= 0 {
t.Fatalf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid")
}
ttl = parsed
}
return tencentim.RESTConfig{
SDKAppID: sdkAppID,
SecretKey: requiredRoomRPSEnv(t, "TENCENT_IM_SECRET_KEY"),
AdminIdentifier: requiredRoomRPSEnv(t, "TENCENT_IM_ADMIN_IDENTIFIER"),
AdminUserSigTTL: ttl,
Endpoint: envOr("TENCENT_IM_ENDPOINT", tencentim.DefaultEndpoint),
GroupType: tencentim.DefaultGroupType,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
func requiredRoomRPSEnv(t *testing.T, key string) string {
t.Helper()
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
t.Fatalf("%s is required for room-rps real IM flow test", key)
}
return value
}
func requiredPositiveInt64Env(t *testing.T, key string) int64 {
t.Helper()
value, err := strconv.ParseInt(requiredRoomRPSEnv(t, key), 10, 64)
if err != nil || value <= 0 {
t.Fatalf("%s must be a positive int64", key)
}
return value
}
func positiveInt64EnvOr(t *testing.T, key string, fallback int64) int64 {
t.Helper()
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
t.Fatalf("%s must be a positive int64", key)
}
return parsed
}
func envOr(key string, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func shouldCleanupRoomRPSIMGroup() bool {
raw := strings.TrimSpace(os.Getenv("ROOM_RPS_CLEANUP_IM_GROUP"))
return raw == "" || raw == "1"
}
func urlQueryEscape(value string) string {
return url.QueryEscape(value)
}
func urlPathEscape(value string) string {
return url.PathEscape(value)
}