hyapp-server/pkg/tencentim/rest_client_real_test.go

305 lines
10 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 tencentim
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
if os.Getenv("TENCENT_IM_REAL_TEST") != "1" {
t.Skip("set TENCENT_IM_REAL_TEST=1 and Tencent IM env vars to run the real REST smoke test")
}
cfg := realRESTConfigFromEnv(t)
client, err := NewRESTClient(cfg)
if err != nil {
t.Fatalf("NewRESTClient failed: %v", err)
}
now := time.Now().UTC()
nowMS := now.UnixMilli()
groupID := fmt.Sprintf("hy_codex_bc_r_%d", nowMS)
eventID := fmt.Sprintf("codex_real_im_%d", nowMS)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
// 真实 IM 测试必须使用临时 GroupID避免污染业务全局/区域播报群。
if err := client.EnsureGroup(ctx, GroupSpec{
GroupID: groupID,
Name: "codex real im smoke",
Type: DefaultGroupType,
ApplyJoinOption: "FreeAccess",
}); err != nil {
t.Fatalf("real Tencent IM create group failed: %v", err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cleanupCancel()
if err := client.DestroyGroup(cleanupCtx, groupID); err != nil {
t.Errorf("real Tencent IM destroy group cleanup failed: %v", err)
}
})
memberUserID := nowMS % 9_000_000_000
if err := realImportAccount(ctx, client, memberUserID); err != nil {
t.Fatalf("real Tencent IM account import failed: %v", err)
}
if err := realAddGroupMember(ctx, client, groupID, memberUserID); err != nil {
t.Fatalf("real Tencent IM add group member failed: %v", err)
}
const actorUserID = "323411852102995968"
const targetUserID = "323411852102995969"
c2cEventID := fmt.Sprintf("codex_real_im_c2c_%d", nowMS)
c2cPayload, err := json.Marshal(map[string]any{
"event_id": c2cEventID,
"event_type": "room_user_kicked",
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
"sender_user_id": actorUserID,
"receiver_user_id": targetUserID,
"sent_at_ms": nowMS,
})
if err != nil {
t.Fatalf("marshal c2c smoke payload failed: %v", err)
}
if err := client.PublishUserCustomMessage(ctx, CustomUserMessage{
ToAccount: strconv.FormatInt(memberUserID, 10),
EventID: c2cEventID,
Desc: "codex_real_im_c2c_probe",
Ext: "im_c2c_smoke",
PayloadJSON: c2cPayload,
}); err != nil {
t.Fatalf("real Tencent IM send C2C custom message failed: %v", err)
}
// SyncOtherMachine=2 不同步管理员发送端,因此漫游消息必须从实际接收用户视角回读。
c2cData, c2cCloudData := realWaitForC2CPayload(t, ctx, client, strconv.FormatInt(memberUserID, 10), cfg.AdminIdentifier, c2cEventID, now.Unix())
realAssertStringUserIDs(t, c2cData, map[string]string{
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
"sender_user_id": actorUserID,
"receiver_user_id": targetUserID,
})
realAssertStringUserIDs(t, c2cCloudData, map[string]string{
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
"sender_user_id": actorUserID,
"receiver_user_id": targetUserID,
})
roomEvent := RoomEvent{
GroupID: groupID,
EventID: eventID,
RoomID: "codex-room-smoke",
EventType: "room_mic_change",
ActorUserID: actorUserID,
TargetUserID: targetUserID,
SeatNo: 3,
RoomVersion: 22,
Attributes: map[string]string{
"action": "change",
"from_seat": "1",
"to_seat": "3",
"mic_session_id": "codex-real-im-mic-session",
"target_user_ids": `["323411852102995968","323411852102995969"]`,
},
}
// 使用生产 RoomEvent 序列化和发送入口,避免真实测试绕开本次修复的代码路径。
if err := client.PublishRoomEvent(ctx, roomEvent); err != nil {
t.Fatalf("real Tencent IM send group custom message failed: %v", err)
}
groupData, groupCloudData := realWaitForGroupPayload(t, ctx, client, groupID, eventID)
for source, payload := range map[string]string{"Data": groupData, "CloudCustomData": groupCloudData} {
realAssertStringUserIDs(t, payload, map[string]string{
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
})
var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
t.Fatalf("decode group %s payload failed: %v", source, err)
}
attributes, ok := decoded["attributes"].(map[string]any)
if !ok {
t.Fatalf("group %s payload attributes type = %T, want object", source, decoded["attributes"])
}
var targetUserIDs []any
if err := json.Unmarshal([]byte(fmt.Sprint(attributes["target_user_ids"])), &targetUserIDs); err != nil {
t.Fatalf("decode group %s target_user_ids failed: %v", source, err)
}
if len(targetUserIDs) != 2 || targetUserIDs[0] != actorUserID || targetUserIDs[1] != targetUserID {
t.Fatalf("group %s target_user_ids = %#v, want exact string IDs", source, targetUserIDs)
}
}
if err := client.DeleteGroupMember(ctx, groupID, memberUserID); err != nil {
t.Fatalf("real Tencent IM delete group member failed: %v", err)
}
t.Logf("real Tencent IM transport and history readback passed: sdk_app_id=%d group_id=%s group_event_id=%s c2c_event_id=%s exact_actor_user_id=%s removed_user_id=%d", cfg.SDKAppID, groupID, eventID, c2cEventID, actorUserID, memberUserID)
}
type realHistoryMessage struct {
MsgBody []messageElement `json:"MsgBody"`
CloudCustomData string `json:"CloudCustomData"`
}
type realGroupHistoryResponse struct {
restResponse
RspMsgList []realHistoryMessage `json:"RspMsgList"`
}
type realC2CHistoryResponse struct {
restResponse
MsgList []realHistoryMessage `json:"MsgList"`
}
func realWaitForGroupPayload(t *testing.T, ctx context.Context, client *RESTClient, groupID, eventID string) (string, string) {
t.Helper()
for attempt := 0; attempt < 10; attempt++ {
var response realGroupHistoryResponse
if err := client.post(ctx, "v4/group_open_http_svc/group_msg_get_simple", map[string]any{
"GroupId": groupID,
"ReqMsgNumber": 20,
}, &response); err != nil {
t.Fatalf("real Tencent IM group history request failed: %v", err)
}
if err := response.err(); err != nil {
t.Fatalf("real Tencent IM group history failed: %v", err)
}
if data, cloudData, ok := realFindHistoryPayload(response.RspMsgList, eventID); ok {
return data, cloudData
}
select {
case <-ctx.Done():
t.Fatalf("real Tencent IM group history readback timed out: %v", ctx.Err())
case <-time.After(500 * time.Millisecond):
}
}
t.Fatalf("real Tencent IM group history did not contain event_id=%s", eventID)
return "", ""
}
func realWaitForC2CPayload(t *testing.T, ctx context.Context, client *RESTClient, operatorAccount, peerAccount, eventID string, sentAtSeconds int64) (string, string) {
t.Helper()
for attempt := 0; attempt < 10; attempt++ {
var response realC2CHistoryResponse
if err := client.post(ctx, "v4/openim/admin_getroammsg", map[string]any{
"Operator_Account": operatorAccount,
"Peer_Account": peerAccount,
"MaxCnt": 100,
"MinTime": sentAtSeconds - 60,
"MaxTime": time.Now().UTC().Unix() + 60,
}, &response); err != nil {
t.Fatalf("real Tencent IM C2C history request failed: %v", err)
}
if err := response.err(); err != nil {
t.Fatalf("real Tencent IM C2C history failed: %v", err)
}
if data, cloudData, ok := realFindHistoryPayload(response.MsgList, eventID); ok {
return data, cloudData
}
select {
case <-ctx.Done():
t.Fatalf("real Tencent IM C2C history readback timed out: %v", ctx.Err())
case <-time.After(500 * time.Millisecond):
}
}
t.Fatalf("real Tencent IM C2C history did not contain event_id=%s", eventID)
return "", ""
}
func realFindHistoryPayload(messages []realHistoryMessage, eventID string) (string, string, bool) {
for _, message := range messages {
for _, element := range message.MsgBody {
if element.MsgType != "TIMCustomElem" || !strings.Contains(element.MsgContent.Data, eventID) {
continue
}
return element.MsgContent.Data, message.CloudCustomData, true
}
}
return "", "", false
}
func realAssertStringUserIDs(t *testing.T, payload string, expected map[string]string) {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
t.Fatalf("decode Tencent IM payload failed: %v", err)
}
for key, want := range expected {
got, ok := decoded[key].(string)
if !ok {
t.Fatalf("Tencent IM payload %s type = %T, want string", key, decoded[key])
}
if got != want {
t.Fatalf("Tencent IM payload %s = %q, want %q", key, got, want)
}
}
}
func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error {
return client.ImportAccount(ctx, AccountProfile{
UserID: userID,
Nick: "codex-im-smoke",
})
}
func realAddGroupMember(ctx context.Context, client *RESTClient, groupID string, userID int64) error {
var response restResponse
if err := client.post(ctx, "v4/group_open_http_svc/add_group_member", map[string]any{
"GroupId": groupID,
"Silence": 1,
"MemberList": []map[string]any{
{"Member_Account": strconv.FormatInt(userID, 10)},
},
}, &response); err != nil {
return err
}
return response.err()
}
func realRESTConfigFromEnv(t *testing.T) RESTConfig {
t.Helper()
sdkAppID, err := strconv.ParseInt(requiredEnv(t, "TENCENT_IM_SDK_APP_ID"), 10, 64)
if err != nil || sdkAppID <= 0 {
t.Fatalf("TENCENT_IM_SDK_APP_ID is invalid")
}
endpoint := strings.TrimSpace(os.Getenv("TENCENT_IM_ENDPOINT"))
if endpoint == "" {
endpoint = DefaultEndpoint
}
adminUserSigTTL := 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")
}
adminUserSigTTL = parsed
}
return RESTConfig{
SDKAppID: sdkAppID,
SecretKey: requiredEnv(t, "TENCENT_IM_SECRET_KEY"),
AdminIdentifier: requiredEnv(t, "TENCENT_IM_ADMIN_IDENTIFIER"),
AdminUserSigTTL: adminUserSigTTL,
Endpoint: endpoint,
GroupType: DefaultGroupType,
}
}
func requiredEnv(t *testing.T, key string) string {
t.Helper()
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
t.Fatalf("%s is required for real Tencent IM smoke test", key)
}
return value
}