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