package main import ( "bytes" "context" "crypto/hmac" "crypto/md5" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/golang/mock/gomock" "github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/auth/mock_auth" "github.com/tinode/chat/server/media" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/mock_store" "github.com/tinode/chat/server/store/types" "golang.org/x/crypto/bcrypt" ) type testVerificationStore struct { active *types.VerificationCode get *types.VerificationCode err error created *types.VerificationCode failed bool verified bool consumed bool tokenHash string } type testHTTPInfrastructureStore struct { records map[string]*types.HTTPIdempotencyRecord deadLetters []types.KafkaDeadLetter } type testTenantEntryStore struct { upload *types.PublicUpload application *types.TenantApplication feedback *types.TenantEntryFeedback } func (s *testTenantEntryStore) CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error) { now := time.Now().UTC().Round(time.Millisecond) upload.CreatedAt = now upload.UploadedAt = now s.upload = upload return upload, nil } func (s *testTenantEntryStore) CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error) { now := time.Now().UTC().Round(time.Millisecond) application.Status = types.TenantApplicationStatusReceived application.EstimatedReviewHour = 24 application.SubmittedAt = now s.application = application return application, nil } func (s *testTenantEntryStore) CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error) { now := time.Now().UTC().Round(time.Millisecond) feedback.Status = types.TenantEntryFeedbackReceived feedback.SubmittedAt = now s.feedback = feedback return feedback, nil } type testPublicMediaHandler struct { def *media.PublicFileDef data string } func (h *testPublicMediaHandler) Init(string) error { return nil } func (h *testPublicMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) { return nil, 0, nil } func (h *testPublicMediaHandler) Upload(*types.FileDef, io.Reader) (string, int64, error) { return "", 0, types.ErrUnsupported } func (h *testPublicMediaHandler) Download(types.TenantID, string) (*types.FileDef, media.ReadSeekCloser, error) { return nil, nil, types.ErrUnsupported } func (h *testPublicMediaHandler) Delete([]string) error { return nil } func (h *testPublicMediaHandler) GetIdFromUrl(string) types.Uid { return types.ZeroUid } func (h *testPublicMediaHandler) UploadPublic(def *media.PublicFileDef, file io.Reader) (string, int64, error) { data, _ := io.ReadAll(file) h.def = def h.data = string(data) def.Location = "public/" + def.Purpose + "/" + def.ID def.ETag = "etag" return def.Location, int64(len(data)), nil } func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string { return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key } func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) { if s.records == nil { s.records = make(map[string]*types.HTTPIdempotencyRecord) } key := s.recordKey(record) if existing := s.records[key]; existing != nil { copy := *existing copy.ResponseBody = append([]byte(nil), existing.ResponseBody...) return ©, false, nil } copy := *record copy.State = types.HTTPIdempotencyProcessing s.records[key] = © return ©, true, nil } func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error { copy := *record copy.State = types.HTTPIdempotencyCompleted copy.ResponseBody = append([]byte(nil), record.ResponseBody...) s.records[s.recordKey(record)] = © return nil } func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error { delete(s.records, s.recordKey(record)) return nil } func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record *types.KafkaConsumeRecord, _ time.Time) (*types.KafkaConsumeRecord, bool, error) { copy := *record copy.Status = types.KafkaConsumeProcessing return ©, true, nil } func (s *testHTTPInfrastructureStore) MarkKafkaConsumeSucceeded(_ types.TenantID, _, _ string, _ time.Time) error { return nil } func (s *testHTTPInfrastructureStore) MarkKafkaConsumeFailed(_ types.TenantID, eventID, group, reason, message string, _ time.Time) (*types.KafkaConsumeRecord, error) { return &types.KafkaConsumeRecord{EventID: eventID, ConsumerGroup: group, Status: types.KafkaConsumeFailed, RetryCount: 1, LastReason: reason, LastError: message}, nil } func (s *testHTTPInfrastructureStore) MarkKafkaConsumeDead(_ types.TenantID, dead *types.KafkaDeadLetter, _ time.Time) error { if dead != nil { s.deadLetters = append(s.deadLetters, *dead) } return nil } func (s *testHTTPInfrastructureStore) ListKafkaDeadLetters(_ types.TenantID, _ types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error) { return &types.KafkaDeadLetterPage{}, nil } func (s *testHTTPInfrastructureStore) GetKafkaDeadLetter(_ types.TenantID, _ string) (*types.KafkaDeadLetter, error) { return nil, types.ErrNotFound } func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetter(_ types.TenantID, _, _, _, _ string, _ time.Time) error { return nil } func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID, deadID, _, _ string, _ time.Time) (*types.KafkaDeadLetter, error) { return &types.KafkaDeadLetter{DeadID: deadID, Status: types.KafkaDeadStatusPendingReplay}, nil } type testRelationsStore struct { context *types.RelationshipContext created *types.FriendRequest blocked *types.UserBlock deleted bool canSend *bool reason string pendingCount int countTenant types.TenantID countUser types.Uid friends []types.FriendListItem friendRequests []types.FriendRequestListItem searchResults []types.UserSearchItem blocks []types.BlockListItem deleteTenant types.TenantID deleteUser types.Uid deletePeer types.Uid } type testFavoritesStore struct { items []types.FavoriteItem created *types.FavoriteItem deleted []string } type testCallsStore struct { items []types.CallRecord topic string listed bool tenant types.TenantID user types.Uid limit int cursor string next string } type testThirdPartyConfigStore map[string]string func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]string, error) { result := make(map[string]string, len(keys)) for _, key := range keys { result[key] = s[key] } return result, nil } type testConversationsStore struct { topic string settings *types.ConversationSettings settingsPatch types.ConversationSettingsPatch draft *types.TopicDraft putDraftCalled bool } type testWorkbenchStore struct { modules []types.WorkbenchModule version int tenantID types.TenantID uid types.Uid calledOnce bool } type testOrganizationStore struct { departments []types.OrganizationDepartment members []types.OrganizationMember searchItems []types.OrganizationSearchItem parentID string departmentID string query string resultTypes string } type testMeetingsStore struct { meeting *types.Meeting list []types.Meeting listCursor string listLimit int attendeeIDs []types.Uid created *types.Meeting response string responseVersion int } type testMeetingNotesStore struct { meetings []types.Meeting files []types.MeetingNoteFile query string cursor string limit int createdMeetingID string createdFileID string createdIdempotencyKey string createdFile *types.MeetingNoteFile } type testDailyReportsStore struct { report *types.DailyReport saved bool submitted bool remindKeys string } type testAttendanceStore struct { config *types.AttendanceConfig records []types.AttendanceRecord checkinRequest types.AttendanceCheckinRequest appeal *types.AttendanceAppeal idempotencyKey string } type testMonthlyReportsStore struct { report *types.MonthlyReport saved bool submitted bool } type testMailboxStore struct { config *types.MailboxConfig submission *types.MailboxSubmission idempotencyKey string submitErr error } type testUserSecurityStore struct { unreadByPrefix map[string]int notifications []types.SystemNotification err error } type testProfilesStore struct { profile *types.UserProfile privacy *types.UserPrivacySettings } type testAppReleaseStore struct { release *types.AppRelease } type testDevicePushTokenStore struct { token *types.DevicePushToken tenantID types.TenantID } type testSafetyStore struct { riskChallenge *types.RiskChallenge riskErr error reasons []types.AbuseReason abuseReport *types.AbuseReport feedback *types.FeedbackSubmission tenantID types.TenantID uid types.Uid idempotency string decision string actionTokenID string targetType string } type testGroupBusinessStore struct { settings *types.GroupSettings member *types.GroupMember joinRequests []types.GroupJoinRequest announcement *types.GroupAnnouncement announcements map[string]*types.GroupAnnouncement mutes []types.GroupMute files []types.GroupFile invitations []types.GroupInvitation listMembers []types.GroupMember listMembersOpts types.GroupMemberListOptions listMembersNext string updatedSettings types.GroupSettingsPatch updatedMembers []types.GroupMemberPatch updatedRoleMode types.AccessMode updateSettingsErr error updateMemberErr error } func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any, settings *types.GroupSettings, _ []types.Uid, _ any, _ time.Time) error { s.settings = settings return nil } func (s *testGroupBusinessStore) CreateSettings(_ types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) { s.settings = settings return settings, nil } func (s *testGroupBusinessStore) GetSettings(_ types.TenantID, topic string) (*types.GroupSettings, error) { if s.settings != nil { return s.settings, nil } return &types.GroupSettings{Topic: topic, OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive, MemberUploadFiles: true, MemberPrivateChat: true, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, MemberCount: 3, Version: 1}, nil } func (s *testGroupBusinessStore) UpdateSettings(_ types.TenantID, topic string, patch types.GroupSettingsPatch) (*types.GroupSettings, error) { s.updatedSettings = patch if s.updateSettingsErr != nil { return nil, s.updateSettingsErr } settings, _ := s.GetSettings(0, topic) if patch.Name != nil { settings.Name = *patch.Name } if patch.Description != nil { settings.Description = *patch.Description } if patch.JoinApproval != nil { settings.JoinApproval = *patch.JoinApproval } if patch.OwnerUserID != nil { settings.OwnerUserID = *patch.OwnerUserID } settings.Version++ s.settings = settings return settings, nil } func (s *testGroupBusinessStore) Dissolve(_ types.TenantID, topic string, actor types.Uid, reason string, now time.Time) (*types.GroupSettings, error) { settings, _ := s.GetSettings(0, topic) settings.GroupStatus = "dissolved" settings.DissolvedBy = actor settings.DissolvedAt = &now settings.Version++ s.settings = settings return settings, nil } func (s *testGroupBusinessStore) UpsertMember(_ types.TenantID, member *types.GroupMember) (*types.GroupMember, error) { s.member = member return member, nil } func (s *testGroupBusinessStore) GetMember(_ types.TenantID, topic string, uid types.Uid) (*types.GroupMember, error) { return &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, Version: 1}, nil } func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) { s.listMembersOpts = opts return s.listMembers, s.listMembersNext, nil } func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) { if s.updateMemberErr != nil { return nil, s.updateMemberErr } s.updatedMembers = append(s.updatedMembers, patch) return &types.GroupMember{Topic: topic, UserID: uid, Role: defaultStringPtr(patch.Role, types.GroupMemberRoleMember), MemberStatus: types.GroupMemberStatusActive, Version: 2}, nil } func (s *testGroupBusinessStore) CreateInvitation(_ types.TenantID, inv *types.GroupInvitation) (*types.GroupInvitation, error) { if inv.JoinRequestID != "" { for i := len(s.invitations) - 1; i >= 0; i-- { existing := &s.invitations[i] if existing.Topic == inv.Topic && existing.TargetUserID == inv.TargetUserID && existing.Status == types.GroupInvitationStatusPending && existing.JoinRequestID != "" { for j := len(s.joinRequests) - 1; j >= 0; j-- { request := &s.joinRequests[j] if request.RequestID == existing.JoinRequestID && request.Status == types.GroupJoinRequestStatusPending { return existing, nil } } } } } s.invitations = append(s.invitations, *inv) if inv.JoinRequestID != "" { expiresAt := inv.ExpiresAt s.joinRequests = append(s.joinRequests, types.GroupJoinRequest{ RequestID: inv.JoinRequestID, Topic: inv.Topic, ApplicantUserID: inv.TargetUserID, InviterUserID: inv.InviterUserID, Source: "invitation", Message: inv.Message, Status: types.GroupJoinRequestStatusPending, ExpiresAt: &expiresAt, IdempotencyKey: inv.IdempotencyKey, Version: 1, CreatedAt: inv.CreatedAt, UpdatedAt: inv.UpdatedAt, }) } return inv, nil } func (s *testGroupBusinessStore) AcceptInvitation(_ types.TenantID, invitationID string, uid types.Uid, _ any, now time.Time) (*types.GroupInvitation, error) { for i := range s.invitations { if s.invitations[i].InvitationID == invitationID { s.invitations[i].TargetUserID = uid if s.invitations[i].JoinRequestID != "" { return &s.invitations[i], nil } s.invitations[i].Status = types.GroupInvitationStatusJoined s.invitations[i].Result = "joined" s.invitations[i].HandledAt = &now return &s.invitations[i], nil } } return &types.GroupInvitation{InvitationID: invitationID, Topic: "grp_test", TargetUserID: uid, Status: types.GroupInvitationStatusJoined, Result: "joined", ExpiresAt: now.Add(time.Hour), HandledAt: &now}, nil } func (s *testGroupBusinessStore) CreateJoinRequest(_ types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) { for i := len(s.joinRequests) - 1; i >= 0; i-- { existing := &s.joinRequests[i] if existing.Topic == req.Topic && existing.ApplicantUserID == req.ApplicantUserID && existing.Status == types.GroupJoinRequestStatusPending { return existing, nil } } now := time.Now().UTC() req.CreatedAt = now req.UpdatedAt = now s.joinRequests = append(s.joinRequests, *req) return req, nil } func (s *testGroupBusinessStore) ListJoinRequests(_ types.TenantID, _ string, status, _ string, _ int) ([]types.GroupJoinRequest, string, error) { if status == "" || status == "all" { return s.joinRequests, "", nil } out := []types.GroupJoinRequest{} for _, item := range s.joinRequests { if item.Status == status { out = append(out, item) } } return out, "", nil } func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, _ any, now time.Time) (*types.GroupJoinRequest, error) { status := types.GroupJoinRequestStatusRejected if action == "accept" { status = types.GroupJoinRequestStatusAccepted } return &types.GroupJoinRequest{RequestID: requestID, Topic: topic, ApplicantUserID: types.Uid(43), Status: status, HandledBy: actor, HandledAt: &now, Version: 2, CreatedAt: now, UpdatedAt: now}, nil } func (s *testGroupBusinessStore) LatestAnnouncement(_ types.TenantID, topic string) (*types.GroupAnnouncement, error) { if s.announcement == nil { return nil, types.ErrNotFound } return s.announcement, nil } func (s *testGroupBusinessStore) GetAnnouncement(_ types.TenantID, _ string, announcementID string) (*types.GroupAnnouncement, error) { if s.announcements != nil { if ann := s.announcements[announcementID]; ann != nil { return ann, nil } } if s.announcement != nil && s.announcement.AnnouncementID == announcementID { return s.announcement, nil } return nil, types.ErrNotFound } func (s *testGroupBusinessStore) PutAnnouncement(_ types.TenantID, ann *types.GroupAnnouncement, _ int) (*types.GroupAnnouncement, error) { ann.Version = 2 if ann.PublishedAt.IsZero() { ann.PublishedAt = time.Now().UTC() } s.announcement = ann return ann, nil } func (s *testGroupBusinessStore) ReadAnnouncement(_ types.TenantID, _, _ string, _ types.Uid, _ time.Time) error { return nil } func (s *testGroupBusinessStore) ListMutes(_ types.TenantID, _ string, _ string, _ int) ([]types.GroupMute, string, error) { return s.mutes, "", nil } func (s *testGroupBusinessStore) UpsertMute(_ types.TenantID, mute *types.GroupMute) (*types.GroupMute, error) { mute.Version = 1 s.mutes = append(s.mutes, *mute) return mute, nil } func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types.Uid, _ types.Uid, _ time.Time) error { return nil } func (s *testGroupBusinessStore) ListFiles(_ types.TenantID, _ string, _ string, _ string, _ string, _ int) ([]types.GroupFile, string, error) { return s.files, "", nil } func (s *testGroupBusinessStore) CreateFile(_ types.TenantID, file *types.GroupFile) (*types.GroupFile, error) { if file.GroupFileID == "" { file.GroupFileID = "gfile_test" } now := time.Now().UTC().Round(time.Millisecond) file.CreatedAt = now file.UpdatedAt = now s.files = append(s.files, *file) return file, nil } func (s *testGroupBusinessStore) DeleteFile(_ types.TenantID, topic, groupFileID string, actor types.Uid, _ bool, now time.Time) (*types.GroupFile, error) { for i := range s.files { if s.files[i].Topic == topic && s.files[i].GroupFileID == groupFileID { s.files[i].Status = "deleted" s.files[i].DeletedBy = actor s.files[i].DeletedAt = &now return &s.files[i], nil } } return &types.GroupFile{Topic: topic, GroupFileID: groupFileID, Status: "deleted", DeletedBy: actor, DeletedAt: &now, CreatedAt: now, UpdatedAt: now}, nil } func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid, message, idempotencyKey string, _ any, now time.Time) (bool, error) { for _, invitation := range s.invitations { if invitation.Topic == topic && invitation.TargetUserID == target { return false, nil } } s.invitations = append(s.invitations, types.GroupInvitation{Topic: topic, InviterUserID: inviter, TargetUserID: target, Message: message, IdempotencyKey: idempotencyKey, Status: types.GroupInvitationStatusJoined, CreatedAt: now, UpdatedAt: now}) return true, nil } func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid, _ time.Time) error { return nil } func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid, role string, mode types.AccessMode, _ time.Time) error { s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role}) s.updatedRoleMode = mode s.member = &types.GroupMember{Topic: topic, UserID: target, Role: role, MemberStatus: types.GroupMemberStatusActive} return nil } func (s *testGroupBusinessStore) TransferOwner(_ types.TenantID, topic string, _, target types.Uid, _ int, _ time.Time) error { settings, _ := s.GetSettings(0, topic) settings.OwnerUserID = target s.settings = settings return nil } func (s *testGroupBusinessStore) CanPost(_ types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) { if admin { return true, nil } if s.settings != nil && s.settings.Topic == topic && s.settings.MuteAll { return false, nil } for _, mute := range s.mutes { if mute.Topic == topic && mute.UserID == uid && mute.Status == types.GroupMuteStatusActive && (mute.ExpiresAt == nil || mute.ExpiresAt.After(now)) { return false, nil } } return true, nil } func defaultStringPtr(value *string, fallback string) string { if value == nil { return fallback } return *value } func (s *testWorkbenchStore) Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) { s.tenantID = tenantID s.uid = uid s.calledOnce = true if s.version == 0 { s.version = 1 } return s.modules, s.version, nil } func (s *testOrganizationStore) ListDepartments(_ types.TenantID, _ types.Uid, parentID, _ string, _ int) ([]types.OrganizationDepartment, string, error) { s.parentID = parentID return s.departments, "", nil } func (s *testOrganizationStore) ListMembers(_ types.TenantID, _ types.Uid, departmentID, _ string, _ int) ([]types.OrganizationMember, string, error) { s.departmentID = departmentID return s.members, "", nil } func (s *testOrganizationStore) ListAllMembers(_ types.TenantID, _ types.Uid, departmentID, queryText, _ string, _ int) ([]types.OrganizationMember, string, error) { s.departmentID = departmentID s.query = queryText return s.members, "", nil } func (s *testOrganizationStore) Search(_ types.TenantID, _ types.Uid, queryText, resultTypes, _ string, _ int) ([]types.OrganizationSearchItem, string, error) { s.query = queryText s.resultTypes = resultTypes return s.searchItems, "", nil } func (s *testMeetingsStore) Create(_ types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) { s.created = meeting s.attendeeIDs = attendeeIDs now := time.Now().UTC().Round(time.Millisecond) meeting.OrganizerID = uid meeting.State = types.MeetingStateScheduled meeting.Version = 1 meeting.CreatedAt = now meeting.UpdatedAt = now meeting.Participants = []types.MeetingParticipant{ {MeetingID: meeting.MeetingID, UserID: uid, Response: types.MeetingResponseAccepted, ResponseAt: &now}, } for _, attendeeID := range attendeeIDs { meeting.Participants = append(meeting.Participants, types.MeetingParticipant{MeetingID: meeting.MeetingID, UserID: attendeeID, Response: types.MeetingResponsePending}) } s.meeting = meeting return meeting, nil } func (s *testMeetingsStore) List(_ types.TenantID, _ types.Uid, cursor string, limit int) ([]types.Meeting, string, error) { s.listCursor = cursor s.listLimit = limit return s.list, "next-1", nil } func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) { return s.meeting, nil } func (s *testMeetingsStore) Update(_ types.TenantID, _ types.Uid, _ string, _ types.MeetingPatch, _ string) (*types.Meeting, error) { return s.meeting, nil } func (s *testMeetingsStore) Cancel(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.Meeting, error) { s.meeting.State = types.MeetingStateCanceled return s.meeting, nil } func (s *testMeetingsStore) Remind(_ types.TenantID, _ types.Uid, _ string, _ string) ([]types.MeetingParticipant, error) { return s.meeting.Participants, nil } func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, response string, version int) (*types.Meeting, error) { s.response = response s.responseVersion = version s.meeting.Version++ for i := range s.meeting.Participants { if s.meeting.Participants[i].UserID == uid { s.meeting.Participants[i].Response = response } } return s.meeting, nil } func (s *testMeetingNotesStore) List(_ types.TenantID, _ types.Uid, query, cursor string, limit int) ([]types.Meeting, string, error) { s.query = query s.cursor = cursor s.limit = limit return s.meetings, "note-next", nil } func (s *testMeetingNotesStore) ListFiles(_ types.TenantID, _ types.Uid, _ string) ([]types.MeetingNoteFile, error) { return s.files, nil } func (s *testMeetingNotesStore) CreateFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) { now := time.Now().UTC().Round(time.Millisecond) s.createdMeetingID = meetingID s.createdFileID = fileID s.createdIdempotencyKey = idempotencyKey if s.createdFile != nil && s.createdFile.MeetingID == meetingID && s.createdFile.UploaderID == uid && s.createdFile.IdempotencyKey == idempotencyKey { file := *s.createdFile return &file, nil } file := &types.MeetingNoteFile{ NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid, FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native", IdempotencyKey: idempotencyKey, Version: 1, CreatedAt: now, UpdatedAt: now, } s.createdFile = file s.files = append(s.files, *file) return file, nil } func (s *testMeetingNotesStore) ReplaceFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) { now := time.Now().UTC().Round(time.Millisecond) return &types.MeetingNoteFile{ NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid, FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native", IdempotencyKey: idempotencyKey, Version: version + 1, CreatedAt: now, UpdatedAt: now, }, nil } func (s *testMeetingNotesStore) DeleteFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, _ string, version int) (*types.MeetingNoteFile, error) { now := time.Now().UTC().Round(time.Millisecond) return &types.MeetingNoteFile{ NoteFileID: noteFileID, MeetingID: meetingID, UploaderID: uid, Status: types.MeetingNoteFileStatusDeleted, Version: version + 1, DeletedBy: uid, DeletedAt: &now, CreatedAt: now, UpdatedAt: now, }, nil } func (s *testDailyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.DailyReport, string, error) { if s.report == nil { return nil, "", nil } return []types.DailyReport{*s.report}, "", nil } func (s *testDailyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) { now := time.Now().UTC().Round(time.Millisecond) s.report = &types.DailyReport{ ReportID: reportID, AuthorID: uid, ReportDate: reportDate, Status: types.DailyReportStatusDraft, ReadState: "unread", Version: 1, CreatedAt: now, UpdatedAt: now, } return s.report, nil } func (s *testDailyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.DailyReport, error) { return s.report, nil } func (s *testDailyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, content string, attachments []string, version int) (*types.DailyReport, error) { s.saved = true s.report.Content = content s.report.Attachments = attachments s.report.Version = version + 1 return s.report, nil } func (s *testDailyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.DailyReport, error) { s.submitted = true now := time.Now().UTC().Round(time.Millisecond) s.report.Status = types.DailyReportStatusSubmitted s.report.SubmittedAt = &now s.report.Version = version + 1 return s.report, nil } func (s *testDailyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ time.Time, _ string) (*types.DailyReportStats, error) { return &types.DailyReportStats{ExpectedCount: 3, SubmittedCount: 1, MissingCount: 2}, nil } func (s *testDailyReportsStore) Remind(_ types.TenantID, _ types.Uid, _ time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) { s.remindKeys = idempotencyKey return userIDs, nil } func (s *testAttendanceStore) Config(_ types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) { if s.config != nil { return s.config, nil } now := time.Now().UTC().Round(time.Millisecond) return &types.AttendanceConfig{ Date: date, Timezone: "Asia/Shanghai", Rule: types.AttendanceRule{ RuleID: "att_rule_default", Name: "默认考勤规则", Workday: true, AllowOutsideCheckin: true, AppealAllowed: true, CreatedAt: now, UpdatedAt: now, }, Shifts: []types.AttendanceShift{{ ShiftID: "shift_default", RuleID: "att_rule_default", Name: "默认班次", WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", CreatedAt: now, UpdatedAt: now, }}, AllowedActions: []string{types.AttendanceActionCheckIn, types.AttendanceActionCheckOut}, TodayRecord: &types.AttendanceRecord{ RecordID: "evt_record", UserID: uid, Date: date, RuleID: "att_rule_default", ShiftID: "shift_default", Status: types.AttendanceStatusNotChecked, Version: 1, CreatedAt: now, UpdatedAt: now, }, CanViewManaged: true, }, nil } func (s *testAttendanceStore) ListRecords(_ types.TenantID, _ types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) { return s.records, opts.Cursor, &types.AttendanceRecordStats{ExpectedCount: 1, CheckedCount: len(s.records)}, nil } func (s *testAttendanceStore) Checkin(_ types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) { s.checkinRequest = req now := time.Now().UTC().Round(time.Millisecond) record := &types.AttendanceRecord{ RecordID: "evt_record", UserID: uid, Date: req.Date, RuleID: "att_rule_default", ShiftID: "shift_default", Status: types.AttendanceStatusCheckedIn, Version: 2, CreatedAt: now, UpdatedAt: now, } checkin := &types.AttendanceCheckin{ CheckinID: checkinID, RecordID: record.RecordID, UserID: uid, Date: req.Date, Action: req.Action, RuleID: record.RuleID, ShiftID: record.ShiftID, ClientTime: req.ClientTime, ServerTime: now, Result: types.AttendanceCheckinNormal, Location: req.Location, Wifi: req.Wifi, Device: req.Device, IdempotencyKey: req.IdempotencyKey, CreatedAt: now, } record.Checkins = []types.AttendanceCheckin{*checkin} return record, checkin, nil } func (s *testAttendanceStore) CreateAppeal(_ types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) { s.appeal = appeal s.idempotencyKey = idempotencyKey now := time.Now().UTC().Round(time.Millisecond) appeal.UserID = uid appeal.Status = types.AttendanceAppealPending appeal.Version = 1 appeal.SubmittedAt = now appeal.CreatedAt = now appeal.UpdatedAt = now return appeal, nil } func (s *testAttendanceStore) ListAppeals(_ types.TenantID, _ types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) { if s.appeal == nil { return nil, opts.Cursor, nil } return []types.AttendanceAppeal{*s.appeal}, opts.Cursor, nil } func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) { if s.report == nil { return nil, "", nil } return []types.MonthlyReport{*s.report}, "", nil } func (s *testMonthlyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) { now := time.Now().UTC().Round(time.Millisecond) s.report = &types.MonthlyReport{ ReportID: reportID, AuthorID: uid, Month: month, Status: types.DailyReportStatusDraft, ReadState: "unread", Version: 1, CreatedAt: now, UpdatedAt: now, } return s.report, nil } func (s *testMonthlyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.MonthlyReport, error) { return s.report, nil } func (s *testMonthlyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) { s.saved = true s.report.WorkSummary = workSummary s.report.NextPlan = nextPlan s.report.Attachments = attachments s.report.Version = version + 1 return s.report, nil } func (s *testMonthlyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) { s.submitted = true now := time.Now().UTC().Round(time.Millisecond) s.report.Status = types.DailyReportStatusSubmitted s.report.SubmittedAt = &now s.report.Version = version + 1 return s.report, nil } func (s *testMonthlyReportsStore) Reopen(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) { s.report.Status = types.DailyReportStatusDraft s.report.SubmittedAt = nil s.report.Version = version + 1 return s.report, nil } func (s *testMonthlyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.MonthlyReportStats, error) { return &types.MonthlyReportStats{ExpectedCount: 4, SubmittedCount: 2, MissingCount: 2}, nil } func (s *testMailboxStore) Config(_ types.TenantID, _ types.Uid) (*types.MailboxConfig, error) { return s.config, nil } func (s *testMailboxStore) Submit(_ types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) { if s.submitErr != nil { return nil, s.submitErr } s.idempotencyKey = idempotencyKey now := time.Now().UTC().Round(time.Millisecond) submission.SenderID = uid submission.Status = types.MailboxSubmissionStatusReceived submission.SubmittedAt = now submission.CreatedAt = now submission.UpdatedAt = now s.submission = submission return submission, nil } func (s *testUserSecurityStore) GetAuthVersion(_ types.TenantID, _ types.Uid) (int, error) { return 0, nil } func (s *testUserSecurityStore) BumpAuthVersion(_ types.TenantID, _ types.Uid, _ time.Time) (int, error) { return 1, nil } func (s *testUserSecurityStore) GetRealName(_ types.TenantID, _ types.Uid) (*types.RealNameVerification, error) { return nil, types.ErrNotFound } func (s *testUserSecurityStore) CreateRealNameSubmission(_ types.TenantID, _ *types.RealNameVerification) error { return nil } func (s *testUserSecurityStore) CreateRealNameAppeal(_ types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) { return appeal, nil } func (s *testUserSecurityStore) CreateActionToken(_ types.TenantID, _ *types.ActionToken) error { return nil } func (s *testUserSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _, _, _ string, _ time.Time) (*types.ActionToken, error) { return nil, types.ErrNotFound } func (s *testUserSecurityStore) GetIndependentPassword(_ types.TenantID, _ types.Uid, _ string) (*types.IndependentPassword, error) { return nil, types.ErrNotFound } func (s *testUserSecurityStore) UpsertIndependentPassword(_ types.TenantID, _ *types.IndependentPassword) error { return nil } func (s *testUserSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, _ types.Uid, _ string, _ int, _ *time.Time, _ time.Time) error { return nil } func (s *testUserSecurityStore) GetYouthMode(_ types.TenantID, _ types.Uid) (*types.YouthModeSettings, error) { return nil, types.ErrNotFound } func (s *testUserSecurityStore) SetYouthMode(_ types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) { return &types.YouthModeSettings{UserID: uid, Enabled: enabled, PolicyVersion: policyVersion, UpdatedAt: now}, nil } func (s *testUserSecurityStore) CreateSystemNotification(_ types.TenantID, notification *types.SystemNotification) error { if s.err != nil { return s.err } if notification != nil { s.notifications = append(s.notifications, *notification) } return nil } func (s *testUserSecurityStore) ListSystemNotifications(_ types.TenantID, _ types.Uid, _ string, _ int) ([]types.SystemNotification, string, error) { return nil, "", nil } func (s *testUserSecurityStore) CountUnreadSystemNotifications(_ types.TenantID, _ types.Uid) (int, error) { total := 0 for _, count := range s.unreadByPrefix { total += count } return total, nil } func (s *testUserSecurityStore) CountUnreadSystemNotificationsByEventPrefix(_ types.TenantID, _ types.Uid, prefix string) (int, error) { if s.unreadByPrefix == nil { return 0, nil } return s.unreadByPrefix[prefix], nil } func (s *testUserSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, _ string, _ time.Time) error { return nil } func (s *testProfilesStore) Get(_ types.TenantID, uid types.Uid) (*types.UserProfile, error) { if s.profile == nil { now := time.Now().UTC().Round(time.Millisecond) s.profile = &types.UserProfile{UserID: uid, PublicAccount: uid.UserId(), Version: 1, CreatedAt: now, UpdatedAt: now} } return s.profile, nil } func (s *testProfilesStore) Update(_ types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error) { profile, _ := s.Get(0, uid) if patch.Nickname != nil { profile.Nickname = *patch.Nickname } if patch.Signature != nil { profile.Signature = *patch.Signature } if patch.Email != nil { profile.Email = *patch.Email } profile.Version = patch.Version + 1 profile.UpdatedAt = time.Now().UTC().Round(time.Millisecond) return profile, nil } func (s *testProfilesStore) GetPrivacy(_ types.TenantID, uid types.Uid) (*types.UserPrivacySettings, error) { if s.privacy == nil { now := time.Now().UTC().Round(time.Millisecond) s.privacy = &types.UserPrivacySettings{ UserID: uid, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, GroupInviteVerification: false, Version: 1, CreatedAt: now, UpdatedAt: now, } } return s.privacy, nil } func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) { privacy, _ := s.GetPrivacy(0, uid) if patch.AddMeMethods != nil { privacy.AddMeMethods = patch.AddMeMethods } if patch.ProfileVisibility != nil { privacy.ProfileVisibility = *patch.ProfileVisibility } if patch.LastSeenVisibility != nil { privacy.LastSeenVisibility = *patch.LastSeenVisibility } if patch.ReadReceipts != nil { privacy.ReadReceipts = *patch.ReadReceipts } if patch.GroupInviteVerification != nil { privacy.GroupInviteVerification = *patch.GroupInviteVerification } privacy.Version = patch.Version + 1 privacy.UpdatedAt = time.Now().UTC().Round(time.Millisecond) return privacy, nil } func (s *testAppReleaseStore) Latest(platform, channel string) (*types.AppRelease, error) { if s.release == nil { now := time.Now().UTC().Round(time.Millisecond) s.release = &types.AppRelease{ Platform: platform, Channel: channel, LatestVersion: "1.0.1", MinSupportedVersion: "1.0.0", UpdateType: "recommended", ReleaseNotes: "修复已知问题。", PublishedAt: now, CreatedAt: now, UpdatedAt: now, } } return s.release, nil } func (s *testDevicePushTokenStore) Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) { now := time.Now().UTC().Round(time.Millisecond) token.CreatedAt = now token.UpdatedAt = now s.token = token s.tenantID = tenantID return token, nil } func (s *testSafetyStore) CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) { now := time.Now().UTC().Round(time.Millisecond) challenge.CreatedAt = now challenge.UpdatedAt = now s.riskChallenge = challenge s.tenantID = tenantID return challenge, nil } func (s *testSafetyStore) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) { if s.riskErr != nil { return nil, s.riskErr } s.tenantID = tenantID s.uid = uid s.decision = decision s.actionTokenID = actionTokenID if s.riskChallenge == nil { s.riskChallenge = &types.RiskChallenge{ChallengeID: challengeID, UserID: uid, Action: "open_url", Level: "high", NoticeCode: "url_high", ExpiresAt: now.Add(time.Minute)} } s.riskChallenge.State = types.RiskChallengeStateCanceled if decision == "continue" { s.riskChallenge.State = types.RiskChallengeStateContinued } s.riskChallenge.Decision = decision s.riskChallenge.ActionTokenID = actionTokenID s.riskChallenge.UpdatedAt = now return s.riskChallenge, nil } func (s *testSafetyStore) AbuseReasons(tenantID types.TenantID, targetType string) ([]types.AbuseReason, error) { s.tenantID = tenantID s.targetType = targetType if len(s.reasons) == 0 { s.reasons = []types.AbuseReason{{TargetType: targetType, ReasonCode: "fraud", Label: "诈骗欺诈", AllowsEvidence: true}} } return s.reasons, nil } func (s *testSafetyStore) CreateAbuseReport(tenantID types.TenantID, report *types.AbuseReport, idempotencyKey string) (*types.AbuseReport, error) { now := time.Now().UTC().Round(time.Millisecond) report.Status = types.AbuseReportStatusReceived report.CreatedAt = now report.UpdatedAt = now s.abuseReport = report s.tenantID = tenantID s.idempotency = idempotencyKey return report, nil } func (s *testSafetyStore) FeedbackConfig(tenantID types.TenantID, uid types.Uid) (*types.FeedbackConfig, error) { s.tenantID = tenantID s.uid = uid now := time.Now().UTC().Round(time.Millisecond) return &types.FeedbackConfig{ Categories: []string{"bug", "suggestion"}, MaxLength: 1000, AttachmentPolicy: map[string]any{"max_files": float64(5), "purpose": "feedback"}, AllowContact: true, DiagnosticsNotice: "仅在同意后上传诊断信息。", UpdatedAt: now, }, nil } func (s *testSafetyStore) CreateFeedback(tenantID types.TenantID, feedback *types.FeedbackSubmission, idempotencyKey string) (*types.FeedbackSubmission, error) { now := time.Now().UTC().Round(time.Millisecond) feedback.Status = types.FeedbackStatusReceived feedback.CreatedAt = now feedback.UpdatedAt = now s.feedback = feedback s.tenantID = tenantID s.idempotency = idempotencyKey return feedback, nil } func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error { return nil } func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int64, _ time.Time) error { return nil } func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int64, _ string, _ int64, _ time.Time) error { return nil } func (s *testCallsStore) List(tenantID types.TenantID, uid types.Uid, topic, cursor string, limit int) ([]types.CallRecord, string, error) { s.listed = true s.tenant = tenantID s.user = uid s.topic = topic s.cursor = cursor s.limit = limit return s.items, s.next, nil } func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid) (string, error) { s.topic = uid.P2PName(peer) return s.topic, nil } func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) { s.topic = topic if s.settings == nil { now := time.Now().UTC().Round(time.Millisecond) s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now} } return s.settings, nil } func (s *testConversationsStore) UpdateSettings(_ types.TenantID, _ types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) { s.settingsPatch = patch settings, _ := s.GetSettings(0, 0, topic) if patch.Pinned != nil { settings.Pinned = *patch.Pinned } if patch.Muted != nil { settings.Muted = *patch.Muted } if patch.Remark != nil { settings.Remark = *patch.Remark } if patch.MessageRetention != nil { settings.MessageRetention = *patch.MessageRetention } settings.Version = patch.Version + 1 settings.UpdatedAt = time.Now().UTC().Round(time.Millisecond) return settings, nil } func (s *testConversationsStore) GetDraft(_ types.TenantID, _ types.Uid, topic string) (*types.TopicDraft, error) { if s.draft == nil { return nil, types.ErrNotFound } s.draft.Topic = topic return s.draft, nil } func (s *testConversationsStore) PutDraft(_ types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) { s.putDraftCalled = true now := time.Now().UTC().Round(time.Millisecond) s.draft = &types.TopicDraft{UserID: uid, Topic: topic, Content: content, EntitiesJSON: entities, Version: version + 1, CreatedAt: now, UpdatedAt: now} return s.draft, nil } func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) { for i := range s.items { existing := &s.items[i] if existing.UserID == item.UserID && existing.SourceTopic == item.SourceTopic && existing.SourceSeq == item.SourceSeq { return existing, nil } if item.IdempotencyKey != "" && existing.UserID == item.UserID && existing.IdempotencyKey == item.IdempotencyKey { return existing, nil } } s.created = item s.items = append(s.items, *item) return item, nil } func (s *testFavoritesStore) List(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FavoriteItem, string, error) { return s.items, "", nil } func (s *testFavoritesStore) Get(_ types.TenantID, _ types.Uid, favoriteID string) (*types.FavoriteItem, error) { for i := range s.items { if s.items[i].FavoriteID == favoriteID { return &s.items[i], nil } } return nil, types.ErrNotFound } func (s *testFavoritesStore) Delete(_ types.TenantID, _ types.Uid, favoriteID string) error { s.deleted = append(s.deleted, favoriteID) return nil } func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) { s.created = req return req, nil } func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) { return s.friendRequests, "", nil } func (s *testRelationsStore) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) { s.countTenant = tenantID s.countUser = uid return s.pendingCount, nil } func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) { return &types.FriendRequest{ TenantID: 7, RequestID: requestID, ApplicantID: 1, TargetID: actor, Status: status, HandledAt: &handledAt, CreatedAt: handledAt, UpdatedAt: handledAt, ExpiresAt: handledAt.Add(time.Hour), }, nil } func (s *testRelationsStore) ListFriends(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendListItem, string, error) { return s.friends, "", nil } func (s *testRelationsStore) GetRelationshipContext(_ types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) { if s.context != nil { return s.context, nil } return &types.RelationshipContext{ TenantID: 7, UserID: userID, PeerID: peerID, Topic: topic, Relationship: types.RelationshipFriend, CanSend: true, }, nil } func (s *testRelationsStore) CanSendP2P(_ types.TenantID, _, _ types.Uid) (bool, string, error) { if s.canSend != nil { return *s.canSend, s.reason, nil } return true, types.RelationshipFriend, nil } func (s *testRelationsStore) BlockUser(tenantID types.TenantID, blockerID, blockedID types.Uid, now time.Time) (*types.UserBlock, error) { s.blocked = &types.UserBlock{TenantID: tenantID, BlockerID: blockerID, BlockedID: blockedID, CreatedAt: now, UpdatedAt: now} return s.blocked, nil } func (s *testRelationsStore) UnblockUser(_ types.TenantID, _, _ types.Uid) error { s.blocked = nil return nil } func (s *testRelationsStore) DeleteFriend(tenantID types.TenantID, userID, peerID types.Uid, _ time.Time) error { s.deleted = true s.deleteTenant = tenantID s.deleteUser = userID s.deletePeer = peerID return nil } func (s *testRelationsStore) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) { friendship := &types.Friendship{TenantID: tenantID, UserID: userID, PeerID: peerID, Status: types.RelationshipFriend, Remark: remark, Topic: userID.P2PName(peerID), CreatedAt: now, UpdatedAt: now} s.context = &types.RelationshipContext{ TenantID: tenantID, UserID: userID, PeerID: peerID, Topic: friendship.Topic, Relationship: types.RelationshipFriend, CanSend: true, Remark: remark, Peer: &types.User{ObjHeader: types.ObjHeader{Id: peerID.String()}, TenantID: tenantID, Public: map[string]any{"display_name": "Ming"}}, } return friendship, nil } func (s *testRelationsStore) SearchUsers(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.UserSearchItem, string, error) { return s.searchResults, "", nil } func (s *testRelationsStore) ListBlocks(_ types.TenantID, _ types.Uid, _ string, _ int) ([]types.BlockListItem, string, error) { return s.blocks, "", nil } func (s *testVerificationStore) Create(_ types.TenantID, vc *types.VerificationCode) error { s.created = vc return s.err } func (s *testVerificationStore) Get(_ types.TenantID, _ string) (*types.VerificationCode, error) { if s.err != nil { return nil, s.err } if s.get == nil { return nil, types.ErrNotFound } return s.get, nil } func (s *testVerificationStore) GetActive(_ types.TenantID, _, _ string, _ time.Time) (*types.VerificationCode, error) { if s.err != nil { return nil, s.err } if s.active == nil { return nil, types.ErrNotFound } return s.active, nil } func (s *testVerificationStore) Fail(_ types.TenantID, _ string, _ types.VerificationState) error { s.failed = true return nil } func (s *testVerificationStore) MarkVerified(_ types.TenantID, _, tokenHash string, _ time.Time) error { s.verified = true s.tokenHash = tokenHash return nil } func (s *testVerificationStore) Consume(_ types.TenantID, _, tokenHash string, _ time.Time) error { s.consumed = true s.tokenHash = tokenHash return nil } func makeTestAPIKey(t *testing.T) string { t.Helper() globals.apiKeySalt = []byte("0123456789abcdef") data := make([]byte, apikeyLength) data[0] = 1 binary.LittleEndian.PutUint32(data[apikeyVersion:], uint32(0)) binary.LittleEndian.PutUint16(data[apikeyVersion+apikeyAppID:], uint16(1)) data[apikeyVersion+apikeyAppID+apikeySequence] = uint8(0) hasher := hmac.New(md5.New, globals.apiKeySalt) hasher.Write(data[:apikeyVersion+apikeyAppID+apikeySequence+apikeyWho]) copy(data[apikeyVersion+apikeyAppID+apikeySequence+apikeyWho:], hasher.Sum(nil)) return base64.URLEncoding.EncodeToString(data) } func decodeIMHTTPResponse(t *testing.T, rec *httptest.ResponseRecorder) imHTTPResponse { t.Helper() var resp imHTTPResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v; body=%s", err, rec.Body.String()) } return resp } func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantID types.TenantID, uid types.Uid) *mock_store.MockPersistentStorageInterface { t.Helper() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).AnyTimes() tokenAuth.EXPECT().IsInitialized().Return(true).AnyTimes() tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte(token)). Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes() store.Store = mockStore t.Cleanup(func() { store.Store = oldStore }) return mockStore } func TestResolveIMHTTPBearerAcceptsBase64Token(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte("token-1"))) principal, err := resolveIMHTTPBearer(req) if err != nil { t.Fatalf("resolve bearer: %v", err) } if principal.TenantID != 7 || principal.Uid != 42 { t.Fatalf("principal mismatch: %#v", principal) } } func TestIMHTTPProtocolHeaders(t *testing.T) { apiKey := makeTestAPIKey(t) called := 0 handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) { called++ writeIMSuccess(wrt, req, map[string]any{"ok": true}) })) req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden || called != 0 { t.Fatalf("missing API key: status=%d called=%d", rec.Code, called) } if rec.Header().Get("X-Request-ID") == "" { t.Fatal("server did not generate X-Request-ID") } req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req.Header.Set("X-MeetingIM-APIKey", apiKey) rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported { t.Fatalf("missing schema: status=%d body=%s", rec.Code, rec.Body.String()) } req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req.Header.Set("X-MeetingIM-APIKey", apiKey) req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-Request-ID", "request-1") rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" { t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID")) } req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req.Header.Set("X-MeetingIM-APIKey", apiKey) req.Header.Set("X-IM-Schema", "1") rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK || called != 2 { t.Fatalf("legacy protocol request: status=%d called=%d", rec.Code, called) } } func TestMeetingIMHTTPAuthHeaders(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v0/file/s/file1", nil) req.Header.Set("X-MeetingIM-APIKey", "new-api-key") req.Header.Set("X-MeetingIM-Auth", "token new-token") if got := getAPIKey(req); got != "new-api-key" { t.Fatalf("getAPIKey new header=%q", got) } method, secret := getHttpAuth(req) if method != "token" || secret != "new-token" { t.Fatalf("getHttpAuth new header method=%q secret=%q", method, secret) } } func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldInfrastructure := store.HTTPInfrastructure infrastructure := &testHTTPInfrastructureStore{} store.HTTPInfrastructure = infrastructure defer func() { store.HTTPInfrastructure = oldInfrastructure }() called := 0 handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) { called++ body, _ := io.ReadAll(req.Body) writeIMSuccess(wrt, req, map[string]any{"body": string(body), "call": called}) })) apiKey := makeTestAPIKey(t) request := func(body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("X-MeetingIM-APIKey", apiKey) req.Header.Set("X-IM-Schema", "1") req.Header.Set("Idempotency-Key", "same-key") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) return rec } first := request(`{"value":1}`) second := request(`{"value":1}`) if first.Code != http.StatusOK || second.Code != http.StatusOK || called != 1 { t.Fatalf("replay failed: first=%d second=%d called=%d", first.Code, second.Code, called) } if second.Header().Get("X-Idempotent-Replay") != "true" || first.Body.String() != second.Body.String() { t.Fatalf("response was not replayed: first=%s second=%s", first.Body.String(), second.Body.String()) } conflict := request(`{"value":2}`) if conflict.Code != http.StatusConflict || decodeIMHTTPResponse(t, conflict).Reason != imReasonIdempotencyConflict || called != 1 { t.Fatalf("conflict failed: status=%d called=%d body=%s", conflict.Code, called, conflict.Body.String()) } } func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", TenantDesc: ptrString("demo tenant"), State: types.TenantStateActive, }} defer func() { store.Tenants = oldTenants }() globals.servingAt = "https://im.example.com/" req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) apiKey := makeTestAPIKey(t) req.Header.Set("X-MeetingIM-APIKey", apiKey) req.Header.Set("X-Tenant-Code", " acme ") req.Header.Set("X-Request-ID", "req-1") rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Code != 0 || resp.RequestID != "req-1" { t.Fatalf("unexpected response envelope: %#v", resp) } data := resp.Data.(map[string]any) tenant := data["tenant"].(map[string]any) if tenant["code"] != "acme" || tenant["name"] != "Acme Corp" { t.Fatalf("unexpected tenant payload: %#v", tenant) } if _, ok := tenant["id"]; ok { t.Fatalf("tenant payload must not expose internal id: %#v", tenant) } endpoints := data["endpoints"].(map[string]any) if endpoints["websocket"] != "wss://im.example.com/v0/channels?apikey="+url.QueryEscape(apiKey) { t.Fatalf("unexpected websocket endpoint: %#v", endpoints) } legal := endpoints["legal"].(map[string]any) if legal["user_terms"] != "https://im.example.com/legal/user-terms" || legal["privacy_policy"] != "https://im.example.com/legal/privacy-policy" { t.Fatalf("unexpected legal endpoints: %#v", legal) } } func TestIMHTTPProtocolDoesNotRequireIdempotencyForTenantResolve(t *testing.T) { next := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { writeIMSuccess(w, req, map[string]any{"ok": true}) }) req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-IM-Schema", "1") req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() withIMHTTPProtocol("/v1/im/", next).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) { oldTenants := store.Tenants oldServingAt := globals.servingAt store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} defer func() { store.Tenants = oldTenants globals.servingAt = oldServingAt }() globals.servingAt = "http://:6060/" req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) req.Host = "internal:6060" req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Host", "api.118im.com") apiKey := makeTestAPIKey(t) req.Header.Set("X-MeetingIM-APIKey", apiKey) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) endpoints := data["endpoints"].(map[string]any) if endpoints["websocket"] != "wss://api.118im.com/v0/channels?apikey="+url.QueryEscape(apiKey) || endpoints["api"] != "https://api.118im.com/v1/im" || endpoints["file"] != "https://api.118im.com/v0/file" { t.Fatalf("unexpected endpoints: %#v", endpoints) } legal := endpoints["legal"].(map[string]any) if legal["user_terms"] != "https://api.118im.com/legal/user-terms" || legal["privacy_policy"] != "https://api.118im.com/legal/privacy-policy" { t.Fatalf("unexpected legal endpoints: %#v", legal) } } func TestServeIMTenantResolveSuccessFromBody(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} defer func() { store.Tenants = oldTenants }() globals.servingAt = "http://127.0.0.1:6060/" req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Code != 0 || resp.Reason != "" { t.Fatalf("unexpected response envelope: %#v", resp) } } func TestServeIMTenantResolveRequiresPost(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/im/tenants/resolve", nil) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusMethodNotAllowed) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonMethodNotAllowed { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMethodNotAllowed) } } func TestServeIMTenantResolveRequiresAPIKey(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme"}`)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonAPIKeyRequired { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonAPIKeyRequired) } } func TestWriteIMRelationErrorUsesResourceNotFoundReason(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/im/test", nil) rec := httptest.NewRecorder() writeIMRelationError(rec, req, types.ErrNotFound) if rec.Code != http.StatusNotFound { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonResourceNotFound { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonResourceNotFound) } } func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusBadRequest) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonMalformedRequest { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMalformedRequest) } } func TestServeIMTenantResolveHidesUnavailableTenantState(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateSuspended, }} defer func() { store.Tenants = oldTenants }() req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonTenantUnavailable { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable) } } func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{err: types.ErrNotFound} defer func() { store.Tenants = oldTenants }() req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusNotFound) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonTenantUnavailable { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable) } } func TestServeIMTenantApplications(t *testing.T) { oldTenantEntry := store.TenantEntry entry := &testTenantEntryStore{} store.TenantEntry = entry defer func() { store.TenantEntry = oldTenantEntry }() req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-applications", strings.NewReader(`{ "credit_code":"91310000ma1k000000", "enterprise_email":"admin@example.com", "license_file_id":"file_123", "tenant_code_hint":"acme", "client_context":{"platform":"android"} }`)) req.Header.Set("Idempotency-Key", "tenant-app-1") rec := httptest.NewRecorder() serveIMTenantApplications(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) } if entry.application == nil || entry.application.CreditCode != "91310000MA1K000000" || entry.application.EnterpriseEmail != "admin@example.com" || entry.application.LicenseFileID != "file_123" { t.Fatalf("unexpected application: %#v", entry.application) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if !strings.HasPrefix(data["application_id"].(string), "entapp_") || data["status"] != types.TenantApplicationStatusReceived { t.Fatalf("unexpected application response: %#v", data) } } func TestServeIMTenantEntryFeedback(t *testing.T) { oldTenantEntry := store.TenantEntry entry := &testTenantEntryStore{} store.TenantEntry = entry defer func() { store.TenantEntry = oldTenantEntry }() req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-entry-feedback", strings.NewReader(`{ "description":"企业码 acme 提示不可用,无法进入企业。", "contact":"user@example.com", "tenant_code_hint":"acme", "screenshot_file_ids":["file_1","file_1","file_2"], "client_context":{"platform":"ios"} }`)) req.Header.Set("Idempotency-Key", "entry-feedback-1") rec := httptest.NewRecorder() serveIMTenantEntryFeedback(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) } if entry.feedback == nil || entry.feedback.Description == "" || len(entry.feedback.ScreenshotFileIDs) != 2 { t.Fatalf("unexpected feedback: %#v", entry.feedback) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if !strings.HasPrefix(data["feedback_id"].(string), "entryfb_") || data["status"] != types.TenantEntryFeedbackReceived { t.Fatalf("unexpected feedback response: %#v", data) } } func TestServeIMPublicUploads(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldTenantEntry := store.TenantEntry entry := &testTenantEntryStore{} handler := &testPublicMediaHandler{} mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes() store.Store = mockStore store.TenantEntry = entry defer func() { store.Store = oldStore store.TenantEntry = oldTenantEntry }() var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("purpose", types.PublicUploadPurposeTenantFeedback); err != nil { t.Fatal(err) } part, err := writer.CreateFormFile("file", "shot.png") if err != nil { t.Fatal(err) } if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil { t.Fatal(err) } if err := writer.Close(); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) rec := httptest.NewRecorder() serveIMPublicUploads(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) } if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeTenantFeedback || entry.upload.Location == "" || handler.def == nil { t.Fatalf("unexpected upload: %#v handler=%#v", entry.upload, handler.def) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if !strings.HasPrefix(data["file_id"].(string), "file_") || data["purpose"] != types.PublicUploadPurposeTenantFeedback { t.Fatalf("unexpected upload response: %#v", data) } } func TestServeIMPublicUploadsAllowsGroupAvatar(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldTenantEntry := store.TenantEntry entry := &testTenantEntryStore{} handler := &testPublicMediaHandler{} mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes() store.Store = mockStore store.TenantEntry = entry defer func() { store.Store = oldStore store.TenantEntry = oldTenantEntry }() var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("purpose", types.PublicUploadPurposeGroupAvatar); err != nil { t.Fatal(err) } part, err := writer.CreateFormFile("file", "avatar.png") if err != nil { t.Fatal(err) } if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil { t.Fatal(err) } if err := writer.Close(); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) rec := httptest.NewRecorder() serveIMPublicUploads(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) } if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeGroupAvatar { t.Fatalf("unexpected upload: %#v", entry.upload) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if !strings.HasPrefix(data["file_id"].(string), "file_") || data["purpose"] != types.PublicUploadPurposeGroupAvatar { t.Fatalf("unexpected upload response: %#v", data) } } func TestServeIMPublicUploadsAllowsUserAvatar(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldTenantEntry := store.TenantEntry entry := &testTenantEntryStore{} handler := &testPublicMediaHandler{} mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes() store.Store = mockStore store.TenantEntry = entry defer func() { store.Store = oldStore store.TenantEntry = oldTenantEntry }() var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("purpose", types.PublicUploadPurposeUserAvatar); err != nil { t.Fatal(err) } part, err := writer.CreateFormFile("file", "avatar.png") if err != nil { t.Fatal(err) } if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil { t.Fatal(err) } if err := writer.Close(); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) rec := httptest.NewRecorder() serveIMPublicUploads(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) } if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeUserAvatar { t.Fatalf("unexpected upload: %#v", entry.upload) } } func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications oldSender := sendIMVerificationCode store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} verifications := &testVerificationStore{} store.Verifications = verifications setRegistrationSMSConfigForTest(true) sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error { t.Fatalf("registration SMS sender must not be called before SMS integration: recipient=%q scene=%q code=%q", recipient, scene, code) return nil } defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications sendIMVerificationCode = oldSender resetRegistrationSMSConfigForTest() }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if verifications.created == nil { t.Fatal("expected challenge to be stored") } if verifications.created.TenantID != 7 || verifications.created.Scene != "register" { t.Fatalf("unexpected stored challenge: %#v", verifications.created) } if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) { t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String()) } if verifications.created.CodeHash != hashIMSecret(7, "register", verifications.created.ChallengeID, verifications.created.PhoneHash, "123456") { t.Fatalf("stored challenge does not match fixed registration code") } resp := decodeIMHTTPResponse(t, rec) data := resp.Data.(map[string]any) if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 { t.Fatalf("unexpected response data: %#v", data) } } func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications oldSender := sendIMVerificationCode store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} verifications := &testVerificationStore{} store.Verifications = verifications setRegistrationSMSConfigForTest(false) sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { t.Fatal("SMS sender must not be called when registration SMS is disabled") return nil } defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications sendIMVerificationCode = oldSender resetRegistrationSMSConfigForTest() }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusOK || verifications.created == nil { t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created) } } func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldTenants := store.Tenants oldVerifications := store.Verifications oldUsers := store.Users oldSender := sendIMVerificationCode store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} verifications := &testVerificationStore{} store.Verifications = verifications users := mock_store.NewMockUsersPersistenceInterface(ctrl) users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil) store.Users = users sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error { if recipient != "+8613800138000" || scene != "reset_password" || len(code) != imVerificationCodeLength { t.Fatalf("unexpected reset delivery: recipient=%q scene=%q code=%q", recipient, scene, code) } return nil } defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications store.Users = oldUsers sendIMVerificationCode = oldSender }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if verifications.created == nil || verifications.created.TargetUser != types.Uid(42) { t.Fatalf("password reset challenge was not bound to phone account: %#v", verifications.created) } } func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications oldUsers := store.Users oldSender := sendIMVerificationCode store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} verifications := &testVerificationStore{} store.Verifications = verifications setRegistrationSMSConfigForTest(true) ctrl := gomock.NewController(t) defer ctrl.Finish() users := mock_store.NewMockUsersPersistenceInterface(ctrl) users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil) store.Users = users sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return types.ErrInternal } defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications store.Users = oldUsers sendIMVerificationCode = oldSender resetRegistrationSMSConfigForTest() }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"reset_password","phone":"+8613800138000"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusOK || verifications.failed { t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String()) } if verifications.created == nil || verifications.created.CodeHash != hashIMSecret(7, "reset_password", verifications.created.ChallengeID, verifications.created.PhoneHash, "123456") { t.Fatalf("challenge should remain active with fixed test code: %#v", verifications.created) } } func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonMalformedRequest { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMalformedRequest) } if resp.Details == nil { t.Fatal("expected malformed request details") } } func TestServeIMVerificationCodesRateLimited(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} store.Verifications = &testVerificationStore{active: &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", State: types.VerificationStatePending, ResendAfter: time.Now().UTC().Add(time.Minute), }} defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusTooManyRequests { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonVerificationRate { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonVerificationRate) } } func TestServeIMVerificationCodeVerifySuccess(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} vc := &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash", CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"), State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute), } verifications := &testVerificationStore{get: vc} store.Verifications = verifications defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", bytes.NewBufferString(`{"code":"123456"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodeVerify(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !verifications.verified || verifications.tokenHash == "" { t.Fatalf("expected challenge to be marked verified: %#v", verifications) } resp := decodeIMHTTPResponse(t, rec) data := resp.Data.(map[string]any) if data["verification_token"] == "" { t.Fatalf("missing verification token: %#v", data) } } func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} store.Verifications = &testVerificationStore{get: &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash", CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"), State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute), }} defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", bytes.NewBufferString(`{"code":"654321"}`)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodeVerify(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonCodeInvalid { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonCodeInvalid) } } func TestServeIMRealNameReturnsUnverified(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security store.Security = &testSecurityStore{} defer func() { store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodGet, "/v1/im/security/realname", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMRealName(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "unverified" || data["can_submit"] != true { t.Fatalf("unexpected realname payload: %#v", data) } } func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security security := &testSecurityStore{} store.Security = security defer func() { store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/submissions", bytes.NewBufferString(`{"name":"张三","id_type":"cn_id","id_number":"110101199003071234"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "idem-1") rec := httptest.NewRecorder() serveIMRealNameSubmissions(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.realName == nil || security.realName.Status != "pending" || security.realName.NameCipher == "张三" { t.Fatalf("unexpected stored realname: %#v", security.realName) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "pending" || data["submission_id"] == "" { t.Fatalf("unexpected response: %#v", data) } } func TestServeIMRealNameAppealCreatesPending(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security security := &testSecurityStore{} store.Security = security defer func() { store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/appeals", bytes.NewBufferString(`{"reason":"证件信息需要更正","attachments":["file1"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMRealNameAppeals(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.realNameAppeal == nil || security.realNameAppeal.Reason != "证件信息需要更正" { t.Fatalf("appeal was not stored: %#v", security.realNameAppeal) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "pending" || data["appeal_id"] == "" { t.Fatalf("unexpected appeal response: %#v", data) } } func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldSecurity := store.Security security := &testSecurityStore{actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"}} store.Security = security defer func() { store.Security = oldSecurity }() expectIMBearer(t, ctrl, "token-set", 7, 42) req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password", bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action_token":"action.1"}`)) req.Header.Set("Authorization", "Bearer token-set") rec := httptest.NewRecorder() serveIMIndependentPassword(rec, req) if rec.Code != http.StatusOK { t.Fatalf("set status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.independentPassword == nil || bcrypt.CompareHashAndPassword(security.independentPassword.PasswordHash, []byte("2794")) != nil { t.Fatalf("password was not stored as a valid hash: %#v", security.independentPassword) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["password_set"] != true || data["next_action"] != "enable_youth_mode" || data["enable_action_token"] == "" { t.Fatalf("unexpected set response: %#v", data) } if security.actionToken == nil || security.actionToken.Action != "enable_youth_mode" || security.actionToken.Target != "youth_mode" { t.Fatalf("unexpected enable token after set: %#v", security.actionToken) } expectIMBearer(t, ctrl, "token-verify", 7, 42) req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password/verify", bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action":"close_youth_mode"}`)) req.Header.Set("Authorization", "Bearer token-verify") rec = httptest.NewRecorder() serveIMIndependentPasswordVerify(rec, req) if rec.Code != http.StatusOK { t.Fatalf("verify status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["action"] != "close_youth_mode" || data["action_token"] == "" { t.Fatalf("unexpected verify response: %#v", data) } } func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldVerifications := store.Verifications oldSecurity := store.Security security := &testSecurityStore{} store.Security = security store.Verifications = &testVerificationStore{get: &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", Scene: "sensitive_action", Action: "set_independent_password", PhoneHash: "phone-hash", TargetUser: 42, CodeHash: hashIMSecret(7, "sensitive_action", "challenge-1", "phone-hash", "123456"), State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute), }} defer func() { store.Verifications = oldVerifications store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/challenge-1/verify", bytes.NewBufferString(`{"code":"123456"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMSecurityChallengeVerify(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.actionToken == nil || security.actionToken.Action != "set_independent_password" || security.actionToken.Target != "youth_mode" { t.Fatalf("unexpected action token: %#v", security.actionToken) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["action"] != "set_independent_password" || data["action_token"] == "" { t.Fatalf("unexpected response: %#v", data) } } func TestServeIMSecurityChallengeSupportsGroupSensitiveScenes(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users oldVerifications := store.Verifications oldSecurity := store.Security oldSender := sendIMVerificationCode users := mock_store.NewMockUsersPersistenceInterface(ctrl) users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{ ObjHeader: types.ObjHeader{Id: types.Uid(42).String()}, TenantID: 7, Phone: "+8613800138000", }, nil) verifications := &testVerificationStore{} security := &testSecurityStore{} store.Users = users store.Verifications = verifications store.Security = security sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return nil } defer func() { store.Users = oldUsers store.Verifications = oldVerifications store.Security = oldSecurity sendIMVerificationCode = oldSender }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges", bytes.NewBufferString(`{"scene":"group_owner_transfer","channel":"sms"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMSecurityChallenges(rec, req) if rec.Code != http.StatusOK { t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String()) } if verifications.created == nil || verifications.created.Action != "group_owner_transfer" { t.Fatalf("unexpected created verification: %#v", verifications.created) } verifications.get = verifications.created expectIMBearer(t, ctrl, "token-1", 7, 42) verifyReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/"+verifications.created.ChallengeID+"/verify", bytes.NewBufferString(`{"code":"123456"}`)) verifyReq.Header.Set("Authorization", "Bearer token-1") verifyRec := httptest.NewRecorder() serveIMSecurityChallengeVerify(verifyRec, verifyReq) if verifyRec.Code != http.StatusOK { t.Fatalf("verify status: got %d body=%s", verifyRec.Code, verifyRec.Body.String()) } if security.actionToken == nil || security.actionToken.Action != "group_owner_transfer" || security.actionToken.Target != "group_owner_transfer" { t.Fatalf("unexpected group action token: %#v", security.actionToken) } } func TestServeIMSecurityChallengeCreateEnforcesResendLimit(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users oldVerifications := store.Verifications oldSender := sendIMVerificationCode now := time.Now().UTC() users := mock_store.NewMockUsersPersistenceInterface(ctrl) users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{ ObjHeader: types.ObjHeader{Id: types.Uid(42).String()}, TenantID: 7, Phone: "+8613800138000", }, nil) verifications := &testVerificationStore{active: &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-active", Scene: "sensitive_action", Action: "close_youth_mode", PhoneHash: hashIMSecret(7, "sensitive_action", "+8613800138000", "phone"), TargetUser: 42, State: types.VerificationStatePending, ExpiresAt: now.Add(5 * time.Minute), ResendAfter: now.Add(45 * time.Second), }} store.Users = users store.Verifications = verifications sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return nil } defer func() { store.Users = oldUsers store.Verifications = oldVerifications sendIMVerificationCode = oldSender }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges", bytes.NewBufferString(`{"scene":"close_youth_mode","channel":"sms"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMSecurityChallenges(rec, req) if rec.Code != http.StatusTooManyRequests { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String()) } if verifications.created != nil { t.Fatalf("rate-limited request created a challenge: %#v", verifications.created) } } func TestServeIMSecurityChallengeCreateRejectsUnknownScene(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges", bytes.NewBufferString(`{"scene":"made_up_action","channel":"sms"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMSecurityChallenges(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } } func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security oldWorkbench := store.Workbench oldRelations := store.Relations oldThirdParty := store.ThirdPartyConfig readAt := time.Now().UTC() security := &testSecurityStore{ realName: &types.RealNameVerification{Status: "verified"}, notifications: []types.SystemNotification{ {EventID: "evt-meeting-created", EventType: "meeting.created"}, {EventID: "evt-meeting-updated", EventType: "meeting.updated"}, {EventID: "evt-friend-request", EventType: "friend.request.created"}, {EventID: "evt-daily-report", EventType: "daily_report.reminded"}, {EventID: "evt-meeting-read", EventType: "meeting.created", ReadAt: &readAt}, }, } relations := &testRelationsStore{pendingCount: 2} workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{ {Code: "organization", Name: "组织架构", Route: "/organization", UnreadCount: 3, Enabled: true, Permission: "enabled"}, {Code: "meetings", Name: "会议通知", Route: "/meetings", UnreadCount: 0, Enabled: true, Permission: "enabled"}, {Code: "external", Name: "外链", Route: "https://example.com", UnreadCount: 9, Enabled: true, Permission: "enabled"}, }} store.Security = security store.Relations = relations store.Workbench = workbench store.ThirdPartyConfig = testThirdPartyConfigStore{ "agora_enabled": "1", "agora_auth_mode": "app_id", "agora_app_id": "app123", "agora_token_ttl_seconds": "3600", "audio_call_enabled": "1", "video_call_enabled": "1", } defer func() { store.Security = oldSecurity store.Relations = oldRelations store.Workbench = oldWorkbench store.ThirdPartyConfig = oldThirdParty }() req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMBootstrap(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil { t.Fatalf("bootstrap missing home capabilities: %#v", data) } features := data["features"].(map[string]any) if features["audio_call"] != true || features["video_call"] != true { t.Fatalf("bootstrap call features disabled unexpectedly: %#v", features) } if security.realNameTenant != 7 || security.realNameUser != 42 { t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser) } if security.unreadTenant != 7 || security.unreadUser != 42 { t.Fatalf("bootstrap system unread lookup scope: tenant=%d uid=%s", security.unreadTenant, security.unreadUser) } if relations.countTenant != 7 || relations.countUser != 42 { t.Fatalf("bootstrap friend request count scope: tenant=%d uid=%s", relations.countTenant, relations.countUser) } if !workbench.calledOnce || workbench.tenantID != 7 || workbench.uid != 42 { t.Fatalf("bootstrap workbench lookup scope: store=%#v", workbench) } if data["workbench_version"].(float64) != 9 { t.Fatalf("unexpected bootstrap workbench version: %#v", data) } modules := data["modules"].([]any) if len(modules) != 2 || modules[0].(map[string]any)["code"] != "organization" || modules[1].(map[string]any)["code"] != "meetings" { t.Fatalf("unexpected bootstrap modules: %#v", modules) } if modules[0].(map[string]any)["unread_count"].(float64) != 3 { t.Fatalf("unexpected bootstrap module unread: %#v", modules[0]) } if modules[1].(map[string]any)["unread_count"].(float64) != 2 { t.Fatalf("unexpected bootstrap meetings unread: %#v", modules[1]) } badges := data["entry_badges"].(map[string]any) if badges["new_friends_pending_count"].(float64) != 2 || badges["contacts_total_unread"].(float64) != 2 || badges["system_unread_count"].(float64) != 4 || badges["workbench_total_unread"].(float64) != 5 { t.Fatalf("unexpected bootstrap badges: %#v", badges) } entries := data["entries"].(map[string]any) newFriends := entries["new_friends"].(map[string]any) if newFriends["route"] != "/contacts/new-friends" || newFriends["unread_count"].(float64) != 2 || newFriends["unread_source"] != "business" { t.Fatalf("unexpected new friends entry: %#v", newFriends) } systemMessages := entries["system_messages"].(map[string]any) if systemMessages["topic"] != "sys" || systemMessages["unread_count"].(float64) != 4 || systemMessages["unread_source"] != "business" { t.Fatalf("unexpected system messages entry: %#v", systemMessages) } fileAssistant := entries["file_assistant"].(map[string]any) if fileAssistant["topic"] != "slf" || fileAssistant["unread_source"] != "topic" { t.Fatalf("unexpected file assistant entry: %#v", fileAssistant) } realname := data["security"].(map[string]any)["realname"].(map[string]any) if realname["status"] != "verified" { t.Fatalf("unexpected bootstrap realname status: %#v", realname) } } func TestServeIMWorkbench(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldWorkbench := store.Workbench oldTenants := store.Tenants oldSecurity := store.Security store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{ {Code: "organization", Name: "部门框架", Route: "/organization", SortOrder: 40, Enabled: true, Permission: "enabled"}, {Code: "meetings", Name: "会议", Route: "/meetings", SortOrder: 41, Enabled: true, Permission: "enabled"}, {Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"}, }} store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} store.Security = &testUserSecurityStore{unreadByPrefix: map[string]int{"meeting.": 4}} defer func() { store.Workbench = oldWorkbench store.Tenants = oldTenants store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMWorkbench(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["layout_version"].(float64) != 3 { t.Fatalf("unexpected layout version: %#v", data) } items := data["modules"].([]any) if len(items) != 2 { t.Fatalf("expected external route to be filtered, got %#v", items) } item := items[0].(map[string]any) if item["code"] != "organization" || item["route"] != "/organization" || item["sort_order"].(float64) != 40 { t.Fatalf("unexpected module: %#v", item) } meetingItem := items[1].(map[string]any) if meetingItem["code"] != "meetings" || meetingItem["unread_count"].(float64) != 4 { t.Fatalf("workbench unread count not populated: %#v", meetingItem) } enterprise := data["enterprise"].(map[string]any) if enterprise["tenant_code"] != "acme" { t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"]) } } func TestServeIMOrganizationDepartments(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldOrganization := store.Organization org := &testOrganizationStore{departments: []types.OrganizationDepartment{ {DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", ChildCount: 1, MemberCount: 12, Version: 2}, }} store.Organization = org defer func() { store.Organization = oldOrganization }() req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments?parent_id=root", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMOrganizationDepartments(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || org.parentID != "root" { t.Fatalf("unexpected departments response: %#v parent=%q", items, org.parentID) } item := items[0].(map[string]any) if item["department_id"] != "dep-sales" || item["name"] != "销售部" { t.Fatalf("unexpected department payload: %#v", item) } } func TestServeIMOrganizationDepartmentMembers(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldOrganization := store.Organization org := &testOrganizationStore{members: []types.OrganizationMember{ {DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1}, }} store.Organization = org defer func() { store.Organization = oldOrganization }() req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments/dep-sales/members", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMOrganizationDepartmentResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || org.departmentID != "dep-sales" || data["department_id"] != "dep-sales" { t.Fatalf("unexpected members response: %#v department=%q", data, org.departmentID) } item := items[0].(map[string]any) if item["display_name"] != "张三" || item["position"] != "销售经理" || item["contactable"] != true { t.Fatalf("unexpected member payload: %#v", item) } } func TestServeIMOrganizationMembers(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldOrganization := store.Organization org := &testOrganizationStore{members: []types.OrganizationMember{ {DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1}, }} store.Organization = org defer func() { store.Organization = oldOrganization }() req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/members?department_id=dep-sales&q=%E5%BC%A0", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMOrganizationMembers(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || org.departmentID != "dep-sales" || org.query != "张" || data["has_more"] != false { t.Fatalf("unexpected organization members response: %#v department=%q query=%q", data, org.departmentID, org.query) } } func TestServeIMOrganizationSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldOrganization := store.Organization member := types.OrganizationMember{DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Status: "active", Contactable: true, Version: 1} org := &testOrganizationStore{searchItems: []types.OrganizationSearchItem{ {Type: "department", Department: &types.OrganizationDepartment{DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", Version: 1}}, {Type: "user", Member: &member}, }} store.Organization = org defer func() { store.Organization = oldOrganization }() req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/search?q=%E9%94%80%E5%94%AE&types=department,user", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMOrganizationSearch(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 2 || org.query != "销售" || org.resultTypes != "department,user" { t.Fatalf("unexpected search response: %#v query=%q types=%q", items, org.query, org.resultTypes) } if items[0].(map[string]any)["type"] != "department" || items[1].(map[string]any)["type"] != "user" { t.Fatalf("unexpected search payload: %#v", items) } } func TestServeIMAttendanceConfigAndRecords(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldAttendance := store.Attendance now := time.Now().UTC().Round(time.Millisecond) attendance := &testAttendanceStore{records: []types.AttendanceRecord{{ RecordID: "evt_record", UserID: 42, Date: time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC), RuleID: "att_rule_default", ShiftID: "shift_default", Status: types.AttendanceStatusCheckedIn, Version: 2, CreatedAt: now, UpdatedAt: now, }}} store.Attendance = attendance defer func() { store.Attendance = oldAttendance }() req := httptest.NewRequest(http.MethodGet, "/v1/im/attendance/config?date=2026-08-31", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMAttendanceConfig(rec, req) if rec.Code != http.StatusOK { t.Fatalf("config status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["date"] != "2026-08-31" || data["timezone"] != "Asia/Shanghai" { t.Fatalf("unexpected config: %#v", data) } req = httptest.NewRequest(http.MethodGet, "/v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31", nil) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMAttendanceRecords(rec, req) if rec.Code != http.StatusOK { t.Fatalf("records status=%d body=%s", rec.Code, rec.Body.String()) } data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["record_id"] != "evt_record" { t.Fatalf("unexpected records: %#v", data) } } func TestServeIMAttendanceCheckinAndAppeal(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldAttendance := store.Attendance attendance := &testAttendanceStore{} store.Attendance = attendance defer func() { store.Attendance = oldAttendance }() req := httptest.NewRequest(http.MethodPost, "/v1/im/attendance/checkins", strings.NewReader(`{"date":"2026-08-31","action":"check_in","client_time":"2026-08-31T01:00:00Z","location":{"name":"总部"}}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "att-1") rec := httptest.NewRecorder() serveIMAttendanceCheckins(rec, req) if rec.Code != http.StatusOK { t.Fatalf("checkin status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if attendance.checkinRequest.Action != types.AttendanceActionCheckIn || data["record"] == nil || data["checkin"] == nil { t.Fatalf("unexpected checkin: data=%#v req=%#v", data, attendance.checkinRequest) } req = httptest.NewRequest(http.MethodPost, "/v1/im/attendance/appeals", strings.NewReader(`{"record_id":"evt_record","appeal_type":"late","reason":"地铁延误","target_action":"check_in","target_time":"2026-08-31T00:58:00Z"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "appeal-1") rec = httptest.NewRecorder() serveIMAttendanceAppeals(rec, req) if rec.Code != http.StatusOK { t.Fatalf("appeal status=%d body=%s", rec.Code, rec.Body.String()) } data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) if attendance.idempotencyKey != "appeal-1" || data["status"] != types.AttendanceAppealPending { t.Fatalf("unexpected appeal: data=%#v key=%q", data, attendance.idempotencyKey) } } func TestServeIMMeetingsCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldKafkaBus := kafkaBus oldPublisher := publishKafkaAuditEventEnvelope oldSecurity := store.Security oldMeetings := store.Meetings meetings := &testMeetingsStore{} security := &testSecurityStore{} var auditPublished bool kafkaBus = &kafkaRuntime{} publishKafkaAuditEventEnvelope = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error { auditPublished = topicName == "audit_event" && key == envelope.BizID && envelope.EventType == "audit.meeting.created" && envelope.BizType == "audit" return nil } store.Security = security store.Meetings = meetings defer func() { kafkaBus = oldKafkaBus publishKafkaAuditEventEnvelope = oldPublisher store.Security = oldSecurity store.Meetings = oldMeetings }() body := `{"title":"周会","start_at":"2026-08-14T09:00:00Z","end_at":"2026-08-14T10:00:00Z","location":"A101","attendee_user_ids":["` + types.Uid(43).UserId() + `"],"reminder_minutes":[10]}` req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "mtg-1") rec := httptest.NewRecorder() serveIMMeetings(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if meetings.created == nil || meetings.created.IdempotencyKey != "mtg-1" || meetings.created.Title != "周会" { t.Fatalf("unexpected created meeting: %#v", meetings.created) } if len(meetings.attendeeIDs) != 1 || data["state"] != types.MeetingStateScheduled || data["my_response"] != types.MeetingResponseAccepted { t.Fatalf("unexpected meeting response: %#v attendees=%#v", data, meetings.attendeeIDs) } if !auditPublished { t.Fatalf("meeting create should still publish audit event") } if len(security.notifications) != 0 { t.Fatalf("meeting create HTTP layer must not duplicate workbench notifications: notifications=%#v", security.notifications) } } func TestServeIMMeetingsList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() viewer := types.Uid(42) expectIMBearer(t, ctrl, "token-1", 7, viewer) oldMeetings := store.Meetings oldUsers := store.Users now := time.Now().UTC().Round(time.Millisecond) meetings := &testMeetingsStore{list: []types.Meeting{{ MeetingID: "mtg_test", OrganizerID: viewer, Title: "周会", StartAt: now.Add(time.Hour), EndAt: now.Add(2 * time.Hour), Location: "A101", State: types.MeetingStateScheduled, Version: 1, CreatedAt: now, UpdatedAt: now, Participants: []types.MeetingParticipant{ {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, }, }}} store.Meetings = meetings users := mock_store.NewMockUsersPersistenceInterface(ctrl) organizer := types.User{Public: map[string]any{"fn": "mzb"}} organizer.SetUid(viewer) users.EXPECT().GetAll(types.TenantID(7), viewer).Return([]types.User{organizer}, nil) store.Users = users defer func() { store.Meetings = oldMeetings store.Users = oldUsers }() req := httptest.NewRequest(http.MethodGet, "/v1/im/meetings?limit=20&cursor=c1", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeetings(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || meetings.listCursor != "c1" || meetings.listLimit != 20 { t.Fatalf("unexpected list response: data=%#v cursor=%q limit=%d", data, meetings.listCursor, meetings.listLimit) } if items[0].(map[string]any)["meeting_id"] != "mtg_test" || data["has_more"] != true { t.Fatalf("unexpected meeting payload: %#v", data) } item := items[0].(map[string]any) if item["organizer_display_name"] != "mzb" || item["participant_count"].(float64) != 1 { t.Fatalf("missing meeting display fields: %#v", item) } participants := item["participants"].([]any) if participants[0].(map[string]any)["display_name"] != "mzb" { t.Fatalf("missing participant display name: %#v", participants) } } func TestServeIMMeetingGetResolvesDisplayNames(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() viewer := types.Uid(42) attendee := types.Uid(43) expectIMBearer(t, ctrl, "token-1", 7, viewer) oldMeetings := store.Meetings oldUsers := store.Users now := time.Now().UTC().Round(time.Millisecond) meetings := &testMeetingsStore{meeting: &types.Meeting{ MeetingID: "mtg_test", OrganizerID: viewer, Title: "周会", StartAt: now.Add(time.Hour), EndAt: now.Add(2 * time.Hour), State: types.MeetingStateScheduled, Version: 1, CreatedAt: now, UpdatedAt: now, Participants: []types.MeetingParticipant{ {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, {MeetingID: "mtg_test", UserID: attendee, Response: types.MeetingResponsePending}, }, }} store.Meetings = meetings users := mock_store.NewMockUsersPersistenceInterface(ctrl) organizer := types.User{Public: map[string]any{"fn": "张三"}} organizer.SetUid(viewer) participant := types.User{Public: map[string]any{"fn": "李四"}} participant.SetUid(attendee) users.EXPECT().GetAll(types.TenantID(7), viewer, attendee).Return([]types.User{organizer, participant}, nil) store.Users = users defer func() { store.Meetings = oldMeetings store.Users = oldUsers }() req := httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeetingResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["organizer_display_name"] != "张三" { t.Fatalf("unexpected organizer display name: %#v", data) } participants := data["participants"].([]any) if participants[0].(map[string]any)["display_name"] != "张三" || participants[1].(map[string]any)["display_name"] != "李四" { t.Fatalf("unexpected participant names: %#v", participants) } } func TestServeIMMeetingMyResponse(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() attendee := types.Uid(43) expectIMBearer(t, ctrl, "token-1", 7, attendee) oldMeetings := store.Meetings now := time.Now().UTC().Round(time.Millisecond) meetings := &testMeetingsStore{meeting: &types.Meeting{ MeetingID: "mtg_test", OrganizerID: 42, Title: "周会", StartAt: now.Add(time.Hour), EndAt: now.Add(2 * time.Hour), State: types.MeetingStateScheduled, Version: 3, CreatedAt: now, UpdatedAt: now, Participants: []types.MeetingParticipant{ {MeetingID: "mtg_test", UserID: 42, Response: types.MeetingResponseAccepted}, {MeetingID: "mtg_test", UserID: attendee, Response: types.MeetingResponsePending}, }, }} store.Meetings = meetings defer func() { store.Meetings = oldMeetings }() req := httptest.NewRequest(http.MethodPut, "/v1/im/meetings/mtg_test/my-response", strings.NewReader(`{"response":"accepted","version":3}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeetingResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if meetings.response != types.MeetingResponseAccepted || meetings.responseVersion != 3 || data["my_response"] != types.MeetingResponseAccepted { t.Fatalf("unexpected response: data=%#v store=%#v", data, meetings) } } func TestServeIMMeetingNotesListAndDetail(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() viewer := types.Uid(42) expectIMBearer(t, ctrl, "token-1", 7, viewer) expectIMBearer(t, ctrl, "token-1", 7, viewer) oldMeetings := store.Meetings oldMeetingNotes := store.MeetingNotes oldUsers := store.Users now := time.Now().UTC().Round(time.Millisecond) meeting := types.Meeting{ MeetingID: "mtg_test", OrganizerID: viewer, RecorderID: viewer, Title: "复盘会", StartAt: now.Add(-2 * time.Hour), EndAt: now.Add(-time.Hour), State: types.MeetingStateEnded, Version: 2, CreatedAt: now.Add(-3 * time.Hour), UpdatedAt: now, Participants: []types.MeetingParticipant{ {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, }, } file := types.MeetingNoteFile{ NoteFileID: "mnote_test", MeetingID: "mtg_test", FileID: "file_test", UploaderID: viewer, FileName: "file_test", MIMEType: "application/pdf", FileSize: 123, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native", Version: 1, CreatedAt: now, UpdatedAt: now, } store.Meetings = &testMeetingsStore{meeting: &meeting} notes := &testMeetingNotesStore{meetings: []types.Meeting{meeting}, files: []types.MeetingNoteFile{file}} store.MeetingNotes = notes store.Users = nil defer func() { store.Meetings = oldMeetings store.MeetingNotes = oldMeetingNotes store.Users = oldUsers }() req := httptest.NewRequest(http.MethodGet, "/v1/im/meeting-notes?query=%E5%A4%8D%E7%9B%98&limit=20", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeetingNotes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) item := items[0].(map[string]any) if notes.query != "复盘" || item["note_status"] != types.MeetingNoteStatusAvailable || item["file_count"].(float64) != 1 || data["has_more"] != true { t.Fatalf("unexpected meeting notes list: data=%#v store=%#v", data, notes) } req = httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test/notes", nil) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMMeetingResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String()) } data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) files := data["files"].([]any) meetingData := data["meeting"].(map[string]any) if data["recorder_user_id"] != viewer.UserId() || meetingData["recorder_user_id"] != viewer.UserId() || meetingData["start_at"] == "" || len(files) != 1 || files[0].(map[string]any)["file_id"] != "file_test" { t.Fatalf("unexpected meeting notes detail: %#v", data) } } func TestServeIMMeetingNoteFilesCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() viewer := types.Uid(42) mockStore := expectIMBearer(t, ctrl, "token-1", 7, viewer) mockStore.EXPECT().GetUidString().Return("note-1") mockStore.EXPECT().GetUidString().Return("note-2") now := time.Now().UTC().Round(time.Millisecond) meeting := types.Meeting{ MeetingID: "mtg_test", OrganizerID: viewer, RecorderID: viewer, Title: "纪要会", StartAt: now.Add(-2 * time.Hour), EndAt: now.Add(-time.Hour), State: types.MeetingStateEnded, Version: 1, CreatedAt: now.Add(-3 * time.Hour), UpdatedAt: now, Participants: []types.MeetingParticipant{ {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, }, } oldMeetings := store.Meetings oldMeetingNotes := store.MeetingNotes notes := &testMeetingNotesStore{} store.Meetings = &testMeetingsStore{meeting: &meeting} store.MeetingNotes = notes defer func() { store.Meetings = oldMeetings store.MeetingNotes = oldMeetingNotes }() req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings/mtg_test/notes/files", strings.NewReader(`{"file_id":"file_test"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "note-file-1") rec := httptest.NewRecorder() serveIMMeetingResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["note_file_id"] != "mnote_note-1" || data["file_id"] != "file_test" || data["version"].(float64) != 1 { t.Fatalf("unexpected note file payload: %#v", data) } req = httptest.NewRequest(http.MethodPost, "/v1/im/meetings/mtg_test/notes/files", strings.NewReader(`{"file_id":"file_test"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "note-file-1") rec = httptest.NewRecorder() serveIMMeetingResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("duplicate status=%d body=%s", rec.Code, rec.Body.String()) } duplicate := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if duplicate["note_file_id"] != "mnote_note-1" || len(notes.files) != 1 { t.Fatalf("unexpected idempotent note file payload: data=%#v files=%#v", duplicate, notes.files) } req = httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test/notes", nil) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMMeetingResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String()) } detail := decodeIMHTTPResponse(t, rec).Data.(map[string]any) files := detail["files"].([]any) meetingData := detail["meeting"].(map[string]any) if len(files) != 1 || files[0].(map[string]any)["note_file_id"] != "mnote_note-1" || meetingData["meeting_id"] != "mtg_test" || meetingData["recorder_user_id"] != viewer.UserId() { t.Fatalf("unexpected detail after create: %#v", detail) } } func TestServeIMDailyReportsCreateAndList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldDailyReports := store.DailyReports reports := &testDailyReportsStore{} store.DailyReports = reports defer func() { store.DailyReports = oldDailyReports }() req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily", strings.NewReader(`{"report_date":"2026-08-14"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMDailyReports(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if reports.report == nil || data["report_date"] != "2026-08-14" || data["status"] != types.DailyReportStatusDraft { t.Fatalf("unexpected daily report response: %#v report=%#v", data, reports.report) } } func TestServeIMDailyReportsListIncludesContentPreview(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldDailyReports := store.DailyReports now := time.Now().UTC().Round(time.Millisecond) reports := &testDailyReportsStore{report: &types.DailyReport{ ReportID: "dr_test", AuthorID: 42, ReportDate: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC), Content: strings.Repeat("日报", 80), Status: types.DailyReportStatusDraft, ReadState: "unread", Version: 1, CreatedAt: now, UpdatedAt: now, }} store.DailyReports = reports defer func() { store.DailyReports = oldDailyReports }() req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/daily?scope=mine", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMDailyReports(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) item := items[0].(map[string]any) if item["content_short"] != strings.Repeat("日报", 60)+"..." { t.Fatalf("unexpected daily report preview: %#v", item["content_short"]) } } func TestServeIMDailyReportDraftAndSubmit(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldDailyReports := store.DailyReports now := time.Now().UTC().Round(time.Millisecond) reports := &testDailyReportsStore{report: &types.DailyReport{ ReportID: "dr_test", AuthorID: 42, ReportDate: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC), Status: types.DailyReportStatusDraft, ReadState: "unread", Version: 1, CreatedAt: now, UpdatedAt: now, }} store.DailyReports = reports defer func() { store.DailyReports = oldDailyReports }() req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/daily/dr_test/draft", strings.NewReader(`{"content":"今日完成接口","attachments":["file1"],"version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMDailyReportResource(rec, req) if rec.Code != http.StatusOK || !reports.saved { t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String()) } req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/dr_test/submit", strings.NewReader(`{"version":2}`)) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMDailyReportResource(rec, req) if rec.Code != http.StatusOK || !reports.submitted { t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != types.DailyReportStatusSubmitted || data["submitted_at"] == nil { t.Fatalf("unexpected submit response: %#v", data) } } func TestServeIMDailyReportStatisticsAndReminders(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldDailyReports := store.DailyReports reports := &testDailyReportsStore{} store.DailyReports = reports defer func() { store.DailyReports = oldDailyReports }() req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/daily/statistics?date=2026-08-14", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMDailyReportStatistics(rec, req) if rec.Code != http.StatusOK { t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["expected_count"].(float64) != 3 || data["missing_count"].(float64) != 2 { t.Fatalf("unexpected stats: %#v", data) } req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/reminders", strings.NewReader(`{"date":"2026-08-14","user_ids":["`+types.Uid(43).UserId()+`"]}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "daily-remind-1") rec = httptest.NewRecorder() serveIMDailyReportReminders(rec, req) if rec.Code != http.StatusOK || reports.remindKeys != "daily-remind-1" { t.Fatalf("remind status=%d key=%q body=%s", rec.Code, reports.remindKeys, rec.Body.String()) } } func TestServeIMMonthlyReportsCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMonthlyReports := store.MonthlyReports reports := &testMonthlyReportsStore{} store.MonthlyReports = reports defer func() { store.MonthlyReports = oldMonthlyReports }() req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly", strings.NewReader(`{"month":"2026-08"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMonthlyReports(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if reports.report == nil || data["month"] != "2026-08" || data["action"] != "edit" { t.Fatalf("unexpected monthly report response: %#v report=%#v", data, reports.report) } } func TestServeIMMonthlyReportsListIncludesPreviews(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMonthlyReports := store.MonthlyReports now := time.Now().UTC().Round(time.Millisecond) reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ ReportID: "mr_test", AuthorID: 42, Month: "2026-08", WorkSummary: strings.Repeat("工作", 80), NextPlan: " 下月计划一 \n 下月计划二 ", Status: types.DailyReportStatusDraft, ReadState: "unread", Version: 1, CreatedAt: now, UpdatedAt: now, }} store.MonthlyReports = reports defer func() { store.MonthlyReports = oldMonthlyReports }() req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly?scope=mine", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMonthlyReports(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) item := items[0].(map[string]any) if item["work_summary_short"] != strings.Repeat("工作", 60)+"..." { t.Fatalf("unexpected work summary preview: %#v", item["work_summary_short"]) } if item["next_plan_short"] != "下月计划一 下月计划二" { t.Fatalf("unexpected next plan preview: %#v", item["next_plan_short"]) } } func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMonthlyReports := store.MonthlyReports now := time.Now().UTC().Round(time.Millisecond) reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ ReportID: "mr_test", AuthorID: 42, Month: "2026-08", Status: types.DailyReportStatusDraft, ReadState: "unread", Version: 1, CreatedAt: now, UpdatedAt: now, }} store.MonthlyReports = reports defer func() { store.MonthlyReports = oldMonthlyReports }() req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/monthly/mr_test/draft", strings.NewReader(`{"work_summary":"本月总结","next_plan":"下月计划","attachments":["file1"],"version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMonthlyReportResource(rec, req) if rec.Code != http.StatusOK || !reports.saved { t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String()) } req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/submit", strings.NewReader(`{"version":2}`)) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMMonthlyReportResource(rec, req) if rec.Code != http.StatusOK || !reports.submitted { t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != types.DailyReportStatusSubmitted || data["action"] != "view" { t.Fatalf("unexpected submit response: %#v", data) } } func TestServeIMMonthlyReportReopen(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMonthlyReports := store.MonthlyReports now := time.Now().UTC().Round(time.Millisecond) submittedAt := now.Add(-time.Hour) reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ ReportID: "mr_test", AuthorID: 42, Month: "2026-08", Status: types.DailyReportStatusSubmitted, ReadState: "unread", Version: 3, SubmittedAt: &submittedAt, CreatedAt: now, UpdatedAt: now, }} store.MonthlyReports = reports defer func() { store.MonthlyReports = oldMonthlyReports }() req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/reopen", strings.NewReader(`{"version":3}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMonthlyReportResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != types.DailyReportStatusDraft || data["action"] != "edit" || reports.report.SubmittedAt != nil { t.Fatalf("unexpected reopen response: %#v report=%#v", data, reports.report) } } func TestServeIMMonthlyReportStatistics(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMonthlyReports := store.MonthlyReports reports := &testMonthlyReportsStore{} store.MonthlyReports = reports defer func() { store.MonthlyReports = oldMonthlyReports }() req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly/statistics?month=2026-08", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMonthlyReportStatistics(rec, req) if rec.Code != http.StatusOK { t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["expected_count"].(float64) != 4 || data["submitted_count"].(float64) != 2 { t.Fatalf("unexpected stats: %#v", data) } } func TestServeIMMailboxConfig(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMailbox := store.Mailbox now := time.Now().UTC().Round(time.Millisecond) mailbox := &testMailboxStore{config: &types.MailboxConfig{ Enabled: true, AllowAnonymous: true, RequiredFields: []string{"subject", "content"}, MaxLength: 5000, AttachmentPolicy: map[string]any{"max_files": float64(5)}, Notice: types.MailboxNotice{Title: "总经理信箱", Content: "请确认文案", UpdatedAt: now}, UpdatedAt: now, }} store.Mailbox = mailbox defer func() { store.Mailbox = oldMailbox }() req := httptest.NewRequest(http.MethodGet, "/v1/im/executive-mailbox/config", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMailboxConfig(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["enabled"] != true || data["allow_anonymous"] != true { t.Fatalf("unexpected mailbox config: %#v", data) } notice := data["notice"].(map[string]any) if notice["version"] == "" || notice["title"] != "总经理信箱" { t.Fatalf("unexpected notice: %#v", notice) } } func TestServeIMMailboxSubmission(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMailbox := store.Mailbox mailbox := &testMailboxStore{} store.Mailbox = mailbox defer func() { store.Mailbox = oldMailbox }() body := `{"subject":"建议","content":"希望优化流程","anonymous":true,"attachment_file_ids":["file1"],"notice_version":"v1"}` req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "mailbox-1") rec := httptest.NewRecorder() serveIMMailboxSubmissions(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if mailbox.submission == nil || mailbox.idempotencyKey != "mailbox-1" || data["status"] != types.MailboxSubmissionStatusReceived { t.Fatalf("unexpected submission: data=%#v store=%#v", data, mailbox) } } func TestServeIMMailboxSubmissionRejectsTooLongContent(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMailbox := store.Mailbox store.Mailbox = &testMailboxStore{} defer func() { store.Mailbox = oldMailbox }() body := `{"subject":"建议","content":"` + strings.Repeat("a", 5001) + `","notice_version":"v1"}` req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "mailbox-long") rec := httptest.NewRecorder() serveIMMailboxSubmissions(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonValidationFailed { t.Fatalf("reason=%q body=%s", resp.Reason, rec.Body.String()) } } func TestServeIMMailboxSubmissionMapsPolicyToValidationFailed(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldMailbox := store.Mailbox store.Mailbox = &testMailboxStore{submitErr: types.ErrPolicy} defer func() { store.Mailbox = oldMailbox }() req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(`{"subject":"建议","content":"希望优化流程"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "mailbox-policy") rec := httptest.NewRecorder() serveIMMailboxSubmissions(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonValidationFailed { t.Fatalf("reason=%q body=%s", resp.Reason, rec.Body.String()) } } func TestServeIMMeProfile(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldProfiles := store.Profiles profiles := &testProfilesStore{} store.Profiles = profiles defer func() { store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/profile", strings.NewReader(`{"nickname":"新昵称","signature":"签名","email":"me@example.com","version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeProfile(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 { t.Fatalf("unexpected profile: %#v", data) } } func TestServeIMMePrivacy(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldProfiles := store.Profiles profiles := &testProfilesStore{} store.Profiles = profiles defer func() { store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMePrivacy(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) methods := data["add_me_methods"].(map[string]any) editable := data["editable"].(map[string]any) if methods["phone"] != false || data["read_receipts"] != false || data["group_invite_verification"] != true || editable["group_invite_verification"] != true || data["version"].(float64) != 2 { t.Fatalf("unexpected privacy: %#v", data) } } func TestIMCanViewLastSeen(t *testing.T) { oldProfiles := store.Profiles oldRelations := store.Relations defer func() { store.Profiles = oldProfiles store.Relations = oldRelations }() viewer, target := types.Uid(1), types.Uid(2) profiles := &testProfilesStore{privacy: &types.UserPrivacySettings{ UserID: target, LastSeenVisibility: "everyone", }} store.Profiles = profiles store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipNone, }} if !imCanViewLastSeen(7, viewer, target) { t.Fatal("everyone visibility must expose last seen") } profiles.privacy.LastSeenVisibility = "nobody" if imCanViewLastSeen(7, viewer, target) { t.Fatal("nobody visibility must hide last seen") } profiles.privacy.LastSeenVisibility = "friends" if imCanViewLastSeen(7, viewer, target) { t.Fatal("non-friend must not see friends-only last seen") } store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipFriend, }} if !imCanViewLastSeen(7, viewer, target) { t.Fatal("friend must see friends-only last seen") } } func TestServeIMAppReleaseLatest(t *testing.T) { oldReleases := store.AppReleases store.AppReleases = &testAppReleaseStore{} defer func() { store.AppReleases = oldReleases }() req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.0.0", nil) rec := httptest.NewRecorder() serveIMAppReleaseLatest(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["platform"] != "ios" || data["latest_version"] != "1.0.1" || data["channel"] != "stable" { t.Fatalf("unexpected release payload: %#v", data) } if data["has_update"] != true || data["update_type"] != "recommended" || data["force_update"] != false { t.Fatalf("unexpected update decision: %#v", data) } } func TestServeIMAppReleaseLatestForcesUnsupportedVersion(t *testing.T) { oldReleases := store.AppReleases store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{ Platform: "ios", Channel: "stable", LatestVersion: "2.0.0", MinSupportedVersion: "1.5.0", UpdateType: "recommended", PublishedAt: time.Now().UTC(), }} defer func() { store.AppReleases = oldReleases }() req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.4.9", nil) rec := httptest.NewRecorder() serveIMAppReleaseLatest(rec, req) data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["has_update"] != true || data["update_type"] != "force" || data["force_update"] != true { t.Fatalf("unexpected forced update decision: %#v", data) } } func TestServeIMAppReleaseLatestRequiresCurrentVersion(t *testing.T) { oldReleases := store.AppReleases store.AppReleases = &testAppReleaseStore{} defer func() { store.AppReleases = oldReleases }() req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios", nil) rec := httptest.NewRecorder() serveIMAppReleaseLatest(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } } func TestServeIMAppReleaseLatestReturnsNoUpdateForCurrentVersion(t *testing.T) { oldReleases := store.AppReleases store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{ Platform: "ios", Channel: "stable", LatestVersion: "1.2.0", MinSupportedVersion: "1.0.0", UpdateType: "force", PublishedAt: time.Now().UTC(), }} defer func() { store.AppReleases = oldReleases }() req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.2.0", nil) rec := httptest.NewRecorder() serveIMAppReleaseLatest(rec, req) data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["has_update"] != false || data["update_type"] != "none" || data["force_update"] != false { t.Fatalf("unexpected no-update decision: %#v", data) } } func TestNumericAppVersionComparison(t *testing.T) { left, err := parseNumericAppVersion("v1.10.0") if err != nil { t.Fatal(err) } right, err := parseNumericAppVersion("1.9.9") if err != nil { t.Fatal(err) } if compareNumericAppVersion(left, right) <= 0 { t.Fatal("expected 1.10.0 to be newer than 1.9.9") } } func TestServeIMYouthModeEnableDisable(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldSecurity := store.Security security := &testSecurityStore{ actionToken: &types.ActionToken{Action: "enable_youth_mode", Target: "youth_mode"}, independentPassword: &types.IndependentPassword{PasswordHash: []byte("hash")}, } store.Security = security defer func() { store.Security = oldSecurity }() expectIMBearer(t, ctrl, "token-enable", 7, 42) req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/youth-mode/enable", bytes.NewBufferString(`{"policy_version":2,"action_token":"action.1"}`)) req.Header.Set("Authorization", "Bearer token-enable") rec := httptest.NewRecorder() serveIMYouthModeEnable(rec, req) if rec.Code != http.StatusOK { t.Fatalf("enable status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.consumedAction != "enable_youth_mode" || security.consumedTarget != "youth_mode" { t.Fatalf("unexpected enable token consumption: action=%q target=%q", security.consumedAction, security.consumedTarget) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["enabled"] != true || data["policy_version"].(float64) != 2 { t.Fatalf("unexpected youth enable payload: %#v", data) } items := data["restriction_items"].([]any) if len(items) != 3 { t.Fatalf("restriction_items: got %#v", items) } firstItem := items[0].(map[string]any) if firstItem["code"] != "night_quiet" || firstItem["title"] != "深夜免打扰" || firstItem["enforced"] != false { t.Fatalf("unexpected restriction item: %#v", firstItem) } security.actionToken = &types.ActionToken{Action: "close_youth_mode", Target: "youth_mode"} expectIMBearer(t, ctrl, "token-disable", 7, 42) req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/youth-mode/disable", nil) req.Header.Set("Authorization", "Bearer token-disable") req.Header.Set("X-Action-Token", "action.2") rec = httptest.NewRecorder() serveIMYouthModeDisable(rec, req) if rec.Code != http.StatusOK { t.Fatalf("disable status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["enabled"] != false { t.Fatalf("unexpected youth disable payload: %#v", data) } } func TestServeIMDevicePushToken(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldTokens := store.DevicePushTokens tokenStore := &testDevicePushTokenStore{} store.DevicePushTokens = tokenStore defer func() { store.DevicePushTokens = oldTokens }() expectIMBearer(t, ctrl, "token-1", 7, 42) req := httptest.NewRequest(http.MethodPut, "/v1/im/devices/device-1/push-token", bytes.NewBufferString(`{"provider":"fcm","token":"push-token-1","app_version":"1.0.1","platform":"android"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMDevicePushToken(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if tokenStore.tenantID != 7 || tokenStore.token == nil || tokenStore.token.UserID != 42 || tokenStore.token.DeviceID != "device-1" || tokenStore.token.Token != "push-token-1" { t.Fatalf("unexpected stored push token: %#v", tokenStore.token) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["device_id"] != "device-1" || data["provider"] != "fcm" { t.Fatalf("unexpected response: %#v", data) } } func TestServeIMConversationSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users defer func() { store.Users = oldUsers }() now := time.Now().UTC().Round(time.Millisecond) alice := types.Subscription{ ObjHeader: types.ObjHeader{UpdatedAt: now}, Topic: "p2pAlice", ReadSeqId: 2, RecvSeqId: 3, } alice.SetPublic(map[string]any{"fn": "Alice"}) alice.SetSeqId(5) alice.SetTouchedAt(now) bob := types.Subscription{ ObjHeader: types.ObjHeader{UpdatedAt: now}, Topic: "grpBob", ReadSeqId: 9, } bob.SetPublic(map[string]any{"fn": "Bob Group"}) bob.SetSeqId(9) bob.SetTouchedAt(now) users.EXPECT().GetTopics(types.TenantID(7), types.Uid(42), gomock.Any()).Return([]types.Subscription{alice, bob}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationSearch(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("items length: got %d, want 1; data=%#v", len(items), data) } item := items[0].(map[string]any) if item["topic"] != "p2pAlice" || item["unread"].(float64) != 3 { t.Fatalf("unexpected conversation item: %#v", item) } } func TestServeIMConversationSearchNoDisclosureOnNoMatch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users defer func() { store.Users = oldUsers }() now := time.Now().UTC().Round(time.Millisecond) bob := types.Subscription{ ObjHeader: types.ObjHeader{UpdatedAt: now}, Topic: "grpBob", ReadSeqId: 9, } bob.SetPublic(map[string]any{"fn": "Bob Group"}) bob.SetSeqId(9) bob.SetTouchedAt(now) users.EXPECT().GetTopics(types.TenantID(7), types.Uid(42), gomock.Any()).Return([]types.Subscription{bob}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationSearch(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if len(data["items"].([]any)) != 0 { t.Fatalf("conversation search leaked unmatched topic: %#v", data) } } func TestServeIMConversationSearchExpiredTokenDoesNotQueryTopics(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldUsers := store.Users mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Store = mockStore store.Users = users defer func() { store.Store = oldStore store.Users = oldUsers }() mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("expired-token")).Return(nil, nil, types.ErrExpired) req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil) req.Header.Set("Authorization", "Bearer expired-token") rec := httptest.NewRecorder() serveIMConversationSearch(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) } } func TestServeIMSystemNotificationsListAndRead(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldSecurity := store.Security readAt := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) security := &testSecurityStore{notifications: []types.SystemNotification{{ TenantID: 7, UserID: 42, EventID: "evt-1", EventType: "security.new_device_login", Title: "新设备登录", Summary: "iPhone 登录", Action: types.KVMap{"type": "security_center"}, CreatedAt: time.Now().UTC().Round(time.Millisecond), }, { TenantID: 7, UserID: 42, EventID: "evt-2", EventType: "system.notice", Title: "系统公告", Summary: "已读公告", ReadAt: &readAt, CreatedAt: readAt, }}, nextCursor: "cursor-2"} store.Security = security defer func() { store.Security = oldSecurity }() expectIMBearer(t, ctrl, "token-list", 7, 42) req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications?cursor=cursor-1&limit=2", nil) req.Header.Set("Authorization", "Bearer token-list") rec := httptest.NewRecorder() serveIMSystemNotifications(rec, req) if rec.Code != http.StatusOK { t.Fatalf("list status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 2 { t.Fatalf("unexpected list response: %#v", data) } if _, ok := items[0].(map[string]any)["read_at"]; ok { t.Fatalf("unread notification unexpectedly has read_at: %#v", items[0]) } if items[1].(map[string]any)["read_at"] == nil { t.Fatalf("read notification missing read_at: %#v", items[1]) } if data["next_cursor"] != "cursor-2" || security.listTenant != 7 || security.listUser != 42 || security.listCursor != "cursor-1" || security.listLimit != 2 { t.Fatalf("notification list scope/paging mismatch: data=%#v store=%#v", data, security) } expectIMBearer(t, ctrl, "token-read", 7, 42) req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-1/read", bytes.NewBufferString(`{}`)) req.Header.Set("Authorization", "Bearer token-read") req.Header.Set("Idempotency-Key", "read-1") rec = httptest.NewRecorder() serveIMSystemNotificationRead(rec, req) if rec.Code != http.StatusOK { t.Fatalf("read status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.readTenant != 7 || security.readUser != 42 || security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil { t.Fatalf("notification was not marked read: %#v", security) } } func TestServeIMMessageReceipts(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldTopics := store.Topics oldProfiles := store.Profiles subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) store.Subs = subs store.Topics = topics store.Profiles = &testProfilesStore{} defer func() { store.Subs = oldSubs store.Topics = oldTopics store.Profiles = oldProfiles }() mySub := &types.Subscription{ TenantID: 7, User: "42", Topic: "grpReceipt", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, } subs.EXPECT().Get(types.TenantID(7), "grpReceipt", types.Uid(42), false).Return(mySub, nil) readMember := types.Subscription{User: "101", ReadSeqId: 8, RecvSeqId: 8} readMember.SetPublic(map[string]any{"fn": "Alice"}) deliveredMember := types.Subscription{User: "102", ReadSeqId: 4, RecvSeqId: 8} deliveredMember.SetPublic(map[string]any{"fn": "Bob"}) topics.EXPECT().GetUsers(types.TenantID(7), "grpReceipt", gomock.Any()).Return([]types.Subscription{readMember, deliveredMember}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpReceipt/messages/8/receipts", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageReceipts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != "grpReceipt" || data["seq"].(float64) != 8 { t.Fatalf("unexpected receipt envelope: %#v", data) } if len(data["read"].([]any)) != 1 || len(data["delivered"].([]any)) != 2 { t.Fatalf("unexpected receipt items: %#v", data) } } func TestServeIMGroupMembers(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness oldRelations := store.Relations subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs groupStore := &testGroupBusinessStore{listMembersNext: "101"} store.GroupBusiness = groupStore store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 42, PeerID: types.Uid(101), Topic: types.Uid(42).P2PName(types.Uid(101)), Relationship: types.RelationshipFriend, CanSend: true, Remark: "老王", }} defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Relations = oldRelations }() subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) groupStore.listMembers = []types.GroupMember{{ ID: 101, Topic: "grpMention", UserID: types.Uid(101), Role: types.GroupMemberRoleMember, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, Nickname: "群里小王", Public: map[string]any{"display_name": "Alice", "nickname": "Alice原名", "avatar_file_id": "alice.jpg"}, MemberStatus: types.GroupMemberStatusActive, }} req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true&limit=20&cursor=99", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["display_name"] != "老王" || items[0].(map[string]any)["nickname"] != "Alice原名" || items[0].(map[string]any)["group_nickname"] != "群里小王" || items[0].(map[string]any)["avatar_file_id"] != "alice.jpg" || items[0].(map[string]any)["mentionable"] != true { t.Fatalf("unexpected members response: %#v", data) } if data["next_cursor"] != "101" { t.Fatalf("unexpected cursor: %#v", data) } if data["permissions"].(map[string]any)["can_mention_all"] != true { t.Fatalf("expected can_mention_all permission: %#v", data) } if groupStore.listMembersOpts.Query != "ali" || !groupStore.listMembersOpts.Mentionable || groupStore.listMembersOpts.Limit != 20 || groupStore.listMembersOpts.Cursor != "99" { t.Fatalf("members options were not forwarded: %#v", groupStore.listMembersOpts) } } func TestGroupMemberPayloadUserIDFormats(t *testing.T) { raw := types.Uid(101) fromRaw := groupMemberPayload(types.Subscription{ User: raw.String(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin, }, true, "") if fromRaw["user_id"] != raw.UserId() { t.Fatalf("raw uid user_id: got %#v, want %s", fromRaw["user_id"], raw.UserId()) } prefixed := types.Uid(102) fromPrefixed := groupMemberPayload(types.Subscription{ User: prefixed.UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin, }, true, "") if fromPrefixed["user_id"] != prefixed.UserId() { t.Fatalf("prefixed uid user_id: got %#v, want %s", fromPrefixed["user_id"], prefixed.UserId()) } } func TestServeIMGroupMemberProfile(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldUsers := store.Users oldRelations := store.Relations subs := mock_store.NewMockSubsPersistenceInterface(ctrl) users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Subs = subs store.Users = users peer := types.Uid(101) store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 42, PeerID: peer, Topic: types.Uid(42).P2PName(peer), Relationship: types.RelationshipFriend, CanSend: true, Remark: "老王", }} defer func() { store.Subs = oldSubs store.Users = oldUsers store.Relations = oldRelations }() subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) member := &types.Subscription{TenantID: 7, User: peer.UserId(), Topic: "grpMention", ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin} member.SetPublic(map[string]any{"display_name": "Group Alice"}) subs.EXPECT().Get(types.TenantID(7), "grpMention", peer, false).Return(member, nil) users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "signature": "hi"}, }, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members/"+peer.UserId()+"/profile", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipFriend || data["signature"] != "hi" || data["role"] != "member" { t.Fatalf("unexpected group member profile response: %#v", data) } if data["display_name"] != "老王" { t.Fatalf("group member profile display name should use friend remark: %#v", data) } } func TestServeIMGroupMemberProfileRejectsUserTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) memberID := types.Uid(42).UserId() req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/"+memberID+"/members/"+memberID+"/profile", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } body := decodeIMHTTPResponse(t, rec) if body.Reason != imReasonMalformedRequest { t.Fatalf("reason: got %q, want %q", body.Reason, imReasonMalformedRequest) } details := body.Details.(map[string]any) if details["field"] != "topic" || details["actual"] != memberID { t.Fatalf("unexpected details: %#v", details) } } func TestServeIMGroupList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users oldGroupBusiness := store.GroupBusiness users := mock_store.NewMockUsersPersistenceInterface(ctrl) groupBusiness := &testGroupBusinessStore{settings: &types.GroupSettings{ Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", AvatarFileID: "file-1", MemberCount: 3, Version: 1, UpdatedAt: time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC), }} store.Users = users store.GroupBusiness = groupBusiness defer func() { store.Users = oldUsers store.GroupBusiness = oldGroupBusiness }() sub := types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)}, } sub.SetPublic(map[string]any{"name": "grpAlpha"}) sub.SetSubCnt(0) users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups?category=owned&query=alp", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroups(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["member_count"].(float64) != 3 || items[0].(map[string]any)["my_role"] != "owner" { t.Fatalf("unexpected group list response: %#v", data) } } func TestServeIMGroupCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness oldFiles := store.Files mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) mockStore.EXPECT().GetUidString().Return("newgroup") store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) files := mock_store.NewMockFilePersistenceInterface(ctrl) store.Topics = topics store.Subs = subs store.Files = files groupBusiness := &testGroupBusinessStore{} store.GroupBusiness = groupBusiness defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs store.Files = oldFiles store.GroupBusiness = oldGroupBusiness }() avatarID := types.Uid(101).String() topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{ ObjHeader: types.ObjHeader{Id: "grpnewgroup"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 2, Public: map[string]any{"name": "New Group", "avatar_file_id": avatarID + ".jpg", "description": "Project discussion", "version": 1}, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpnewgroup", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) files.EXPECT().LinkAttachments(types.TenantID(7), "grpnewgroup", types.ZeroUid, []string{avatarID}).Return(nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","description":"Project discussion","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"`+avatarID+`.jpg"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroups(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != "grpnewgroup" || data["name"] != "New Group" || data["description"] != "Project discussion" { t.Fatalf("unexpected group create response: %#v", data) } if groupBusiness.settings == nil || groupBusiness.settings.Description != "Project discussion" { t.Fatalf("group settings missing description: %#v", groupBusiness.settings) } } func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs groupStore := &testGroupBusinessStore{} store.GroupBusiness = groupStore defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, Private: map[string]any{"version": 1, "profile_version": 1}, } group := &types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: map[string]any{"name": "Alpha", "version": 1}, } subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: map[string]any{"name": "Beta", "description": "hello", "version": 2}, }, nil) subs.EXPECT().Update(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return(nil).Times(2) req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"name":"Beta","description":"hello","version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String()) } if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["name"] != "Beta" || data["version"].(float64) != 2 { t.Fatalf("unexpected patch response: %#v", data) } settingsReq := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/members/me/settings", bytes.NewBufferString(`{"pinned":true,"mute_notifications":true,"version":1}`)) settingsReq.Header.Set("Authorization", "Bearer token-1") settingsRec := httptest.NewRecorder() serveIMGroupResource(settingsRec, settingsReq) if settingsRec.Code != http.StatusOK { t.Fatalf("settings status: got %d body=%s", settingsRec.Code, settingsRec.Body.String()) } if data := decodeIMHTTPResponse(t, settingsRec).Data.(map[string]any); data["pinned"] != true || data["mute_notifications"] != true { t.Fatalf("unexpected settings response: %#v", data) } profileReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/members/me/profile", bytes.NewBufferString(`{"nickname":"群昵称","version":1}`)) profileReq.Header.Set("Authorization", "Bearer token-1") profileRec := httptest.NewRecorder() serveIMGroupResource(profileRec, profileReq) if profileRec.Code != http.StatusOK { t.Fatalf("profile status: got %d body=%s", profileRec.Code, profileRec.Body.String()) } if data := decodeIMHTTPResponse(t, profileRec).Data.(map[string]any); data["nickname"] != "群昵称" || data["version"].(float64) != 2 { t.Fatalf("unexpected profile response: %#v", data) } } func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness oldFiles := store.Files mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) files := mock_store.NewMockFilePersistenceInterface(ctrl) store.Topics = topics store.Subs = subs store.Files = files store.GroupBusiness = &testGroupBusinessStore{updateSettingsErr: types.ErrNotFound} defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs store.Files = oldFiles store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, Private: map[string]any{"version": 1, "profile_version": 1}, } group := &types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: map[string]any{"name": "Alpha", "version": 1}, } avatarID := types.Uid(100).String() var savedPublic map[string]any subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) files.EXPECT().LinkAttachments(types.TenantID(7), "grpAlpha", types.ZeroUid, []string{avatarID}).Return(nil) topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).DoAndReturn( func(_ types.TenantID, _ string, update map[string]any) error { public, ok := update["Public"].(map[string]any) if !ok { t.Fatalf("Public update missing: %#v", update) } savedPublic = public return nil }, ) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").DoAndReturn( func(_ types.TenantID, _ string) (*types.Topic, error) { return &types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: savedPublic, }, nil }, ) req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"avatar_file_id":"`+avatarID+`.jpg"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String()) } if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["avatar_file_id"] != avatarID+".jpg" || data["version"].(float64) != 2 { t.Fatalf("unexpected patch response: %#v", data) } } func TestServeIMGroupDetailPatchUsesBusinessSettingsVersion(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{ Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive, Version: 7, }} store.GroupBusiness = groupStore defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, } group := &types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), Public: map[string]any{"name": "Alpha", "version": 1}, } subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), Public: map[string]any{"name": "Beta", "version": 8}, }, nil) req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"name":"Beta","version":7}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String()) } if groupStore.updatedSettings.Version != 7 { t.Fatalf("business settings update used version %d, want 7", groupStore.updatedSettings.Version) } if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["version"].(float64) != 8 { t.Fatalf("unexpected patch response: %#v", data) } } func TestServeIMGroupMyProfileKeepsSubscriptionNicknameWhenBusinessMemberMissing(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs store.GroupBusiness = &testGroupBusinessStore{updateMemberErr: types.ErrNotFound} defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, Private: map[string]any{"comment": "", "profile_version": 1}, } var savedPrivate map[string]any subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) subs.EXPECT().Update(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).DoAndReturn( func(_ types.TenantID, _ string, _ types.Uid, update map[string]any) error { private, ok := update["Private"].(map[string]any) if !ok { t.Fatalf("Private update missing: %#v", update) } savedPrivate = private return nil }, ) req := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/members/me/profile", bytes.NewBufferString(`{"nickname":"Coffee 备注","version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("profile status: got %d body=%s", rec.Code, rec.Body.String()) } if savedPrivate["nickname"] != "Coffee 备注" || savedPrivate["profile_version"] != 2 { t.Fatalf("subscription private not saved: %#v", savedPrivate) } if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["nickname"] != "Coffee 备注" || data["version"].(float64) != 2 { t.Fatalf("unexpected profile response: %#v", data) } } func TestServeIMGroupMyProfileGetReturnsSubscriptionNickname(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs defer func() { store.Subs = oldSubs }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, Private: map[string]any{ "nickname": "Coffee 备注", "profile_version": 3, "profile_updated_at": "2026-09-03T01:00:00Z", }, }, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/members/me/profile", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("profile get status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["nickname"] != "Coffee 备注" || data["version"].(float64) != 3 || data["updated_at"] != "2026-09-03T01:00:00Z" { t.Fatalf("unexpected profile get response: %#v", data) } } func TestServeIMGroupLeave(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldTopics := store.Topics oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs groupStore := &testGroupBusinessStore{} store.GroupBusiness = groupStore defer func() { store.Topics = oldTopics store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/leave", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "left" || data["topic"] != "grpAlpha" { t.Fatalf("unexpected leave response: %#v", data) } } func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness oldSecurity := store.Security subs := mock_store.NewMockSubsPersistenceInterface(ctrl) groupStore := &testGroupBusinessStore{} securityStore := &testUserSecurityStore{} store.Subs = subs store.GroupBusiness = groupStore store.Security = securityStore defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Security = oldSecurity }() owner := types.Uid(42) target := types.Uid(43) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{ TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "invite-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["status"] != types.GroupInvitationStatusJoined { t.Fatalf("unexpected invitation response: %#v", data) } if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target || groupStore.invitations[0].Status != types.GroupInvitationStatusJoined { t.Fatalf("member was not joined directly: %#v", groupStore.invitations) } if len(securityStore.notifications) != 0 { t.Fatalf("direct invite should not create approval notification: %#v", securityStore.notifications) } } func TestServeIMGroupInvitationCreatesApprovalRequest(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("invite") mockStore.EXPECT().GetUidString().Return("request") oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness oldUsers := store.Users subs := mock_store.NewMockSubsPersistenceInterface(ctrl) users := mock_store.NewMockUsersPersistenceInterface(ctrl) groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{ Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive, JoinApproval: true, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, Version: 1, }} store.Subs = subs store.Users = users store.GroupBusiness = groupStore defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Users = oldUsers }() owner := types.Uid(42) target := types.Uid(43) ownerSub := &types.Subscription{ TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, } subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "invite-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) item := items[0].(map[string]any) if item["invitation_id"] != "ginv_invite" || item["join_request_id"] != "gjr_request" || item["status"] != types.GroupInvitationStatusPendingApproval { t.Fatalf("unexpected invitation response: %#v", data) } if len(groupStore.joinRequests) != 1 || groupStore.joinRequests[0].RequestID != "gjr_request" || groupStore.joinRequests[0].ApplicantUserID != target || groupStore.joinRequests[0].Source != "invitation" { t.Fatalf("join request was not created: %#v", groupStore.joinRequests) } req = httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/summary", nil) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("summary status: got %d body=%s", rec.Code, rec.Body.String()) } summary := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if summary["pending_join_request_count"] != float64(1) { t.Fatalf("unexpected summary response: %#v", summary) } users.EXPECT().GetAll(types.TenantID(7), target, owner).Return([]types.User{ {ObjHeader: types.ObjHeader{Id: target.String()}, Public: map[string]any{"display_name": "申请人", "avatar_file_id": "avatar-target.jpg"}}, {ObjHeader: types.ObjHeader{Id: owner.String()}, Public: map[string]any{"display_name": "邀请人", "avatar_file_id": "avatar-owner.jpg"}}, }, nil) req = httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/join-requests?status=pending", nil) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("join requests status: got %d body=%s", rec.Code, rec.Body.String()) } list := decodeIMHTTPResponse(t, rec).Data.(map[string]any) requests := list["items"].([]any) if len(requests) != 1 || requests[0].(map[string]any)["request_id"] != "gjr_request" { t.Fatalf("unexpected join requests response: %#v", list) } requestItem := requests[0].(map[string]any) if requestItem["display_name"] != "申请人" || requestItem["avatar_file_id"] != "avatar-target.jpg" { t.Fatalf("join request applicant profile was not populated: %#v", requestItem) } if requestItem["inviter_display_name"] != "邀请人" || requestItem["inviter_avatar_file_id"] != "avatar-owner.jpg" { t.Fatalf("join request inviter profile was not populated: %#v", requestItem) } if user := requestItem["user"].(map[string]any); user["display_name"] != "申请人" || user["avatar_file_id"] != "avatar-target.jpg" { t.Fatalf("join request user object was not populated: %#v", user) } } func TestDedupePendingGroupJoinRequestsKeepsLatestPerApplicant(t *testing.T) { applicant := types.Uid(43) other := types.Uid(44) items := []types.GroupJoinRequest{ {ID: 10, RequestID: "gjr_old", Topic: "grpAlpha", ApplicantUserID: applicant, Status: types.GroupJoinRequestStatusPending}, {ID: 11, RequestID: "gjr_other", Topic: "grpAlpha", ApplicantUserID: other, Status: types.GroupJoinRequestStatusPending}, {ID: 12, RequestID: "gjr_new", Topic: "grpAlpha", ApplicantUserID: applicant, Status: types.GroupJoinRequestStatusPending}, } deduped := dedupePendingGroupJoinRequests(items, types.GroupJoinRequestStatusPending) if len(deduped) != 2 || deduped[0].RequestID != "gjr_other" || deduped[1].RequestID != "gjr_new" { t.Fatalf("unexpected deduped requests: %#v", deduped) } all := dedupePendingGroupJoinRequests(items, "all") if len(all) != len(items) { t.Fatalf("non-pending list should not be deduped: %#v", all) } } func TestServeIMGroupInvitationRespectsTargetPrivacyApproval(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("invite") mockStore.EXPECT().GetUidString().Return("request") oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness oldProfiles := store.Profiles subs := mock_store.NewMockSubsPersistenceInterface(ctrl) groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{ Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive, JoinApproval: false, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, Version: 1, }} store.Subs = subs store.GroupBusiness = groupStore store.Profiles = &testProfilesStore{privacy: &types.UserPrivacySettings{ UserID: types.Uid(43), AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, GroupInviteVerification: true, Version: 1, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }} defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Profiles = oldProfiles }() owner := types.Uid(42) target := types.Uid(43) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{ TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "invite-privacy") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) item := items[0].(map[string]any) if item["invitation_id"] != "ginv_invite" || item["join_request_id"] != "gjr_request" || item["status"] != types.GroupInvitationStatusPendingApproval { t.Fatalf("unexpected invitation response: %#v", data) } if len(groupStore.joinRequests) != 1 || groupStore.joinRequests[0].ApplicantUserID != target || groupStore.joinRequests[0].Source != "invitation" { t.Fatalf("join request was not created from privacy setting: %#v", groupStore.joinRequests) } } func TestServeIMGroupInvitationAccept(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 43) oldGroupBusiness := store.GroupBusiness groupStore := &testGroupBusinessStore{invitations: []types.GroupInvitation{{ InvitationID: "ginv_test", Topic: "grpAlpha", InviterUserID: 42, TargetUserID: 43, Status: "pending", ExpiresAt: time.Now().UTC().Add(time.Hour), }}} store.GroupBusiness = groupStore defer func() { store.GroupBusiness = oldGroupBusiness }() req := httptest.NewRequest(http.MethodPost, "/v1/im/group-invitations/ginv_test/accept", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupInvitationResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["invitation_id"] != "ginv_test" || data["status"] != types.GroupInvitationStatusJoined || data["result"] != "joined" { t.Fatalf("unexpected invitation accept response: %#v", data) } } func TestServeIMGroupFiles(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness oldFiles := store.Files subs := mock_store.NewMockSubsPersistenceInterface(ctrl) files := mock_store.NewMockFilePersistenceInterface(ctrl) groupStore := &testGroupBusinessStore{} store.Subs = subs store.GroupBusiness = groupStore store.Files = files defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Files = oldFiles }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil).Times(3) fileID := types.Uid(101).String() files.EXPECT().Get(types.TenantID(7), fileID).Return(&types.FileDef{ ObjHeader: types.ObjHeader{Id: fileID}, TenantID: 7, User: types.Uid(42).String(), Status: types.UploadCompleted, MimeType: "application/pdf", Size: 123, }, nil) createReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files", strings.NewReader(fmt.Sprintf(`{"file_id":"%s.pdf","file_name":"方案.pdf","mime_type":"application/pdf","file_type":"document","file_size":123}`, fileID))) createReq.Header.Set("Authorization", "Bearer token-1") createReq.Header.Set("Idempotency-Key", "gfile-1") createRec := httptest.NewRecorder() serveIMGroupResource(createRec, createReq) if createRec.Code != http.StatusOK { t.Fatalf("create status: got %d body=%s", createRec.Code, createRec.Body.String()) } created := decodeIMHTTPResponse(t, createRec).Data.(map[string]any) if created["file_id"] != fileID || created["file_type"] != "document" { t.Fatalf("unexpected create response: %#v", created) } listReq := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/files", nil) listReq.Header.Set("Authorization", "Bearer token-1") listRec := httptest.NewRecorder() serveIMGroupResource(listRec, listReq) if listRec.Code != http.StatusOK { t.Fatalf("list status: got %d body=%s", listRec.Code, listRec.Body.String()) } listed := decodeIMHTTPResponse(t, listRec).Data.(map[string]any) if len(listed["items"].([]any)) != 1 || listed["has_more"] != false { t.Fatalf("unexpected list response: %#v", listed) } deleteReq := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpAlpha/files/gfile_test", nil) deleteReq.Header.Set("Authorization", "Bearer token-1") deleteRec := httptest.NewRecorder() serveIMGroupResource(deleteRec, deleteReq) if deleteRec.Code != http.StatusOK { t.Fatalf("delete status: got %d body=%s", deleteRec.Code, deleteRec.Body.String()) } deleted := decodeIMHTTPResponse(t, deleteRec).Data.(map[string]any) if deleted["status"] != "deleted" || deleted["deleted_by"] != types.Uid(42).UserId() { t.Fatalf("unexpected delete response: %#v", deleted) } } func TestServeIMGroupFileCreateValidatesInput(t *testing.T) { tests := []struct { name string body string setupFiles func(*mock_store.MockFilePersistenceInterface, string) wantReason string }{ { name: "file name too long", body: `{"file_id":"%s.pdf","file_name":"` + strings.Repeat("a", 256) + `"}`, wantReason: imReasonValidationFailed, }, { name: "negative size", body: `{"file_id":"%s.pdf","file_name":"方案.pdf","file_size":-1}`, wantReason: imReasonValidationFailed, }, { name: "missing upload", body: `{"file_id":"%s.pdf","file_name":"方案.pdf"}`, setupFiles: func(files *mock_store.MockFilePersistenceInterface, fileID string) { files.EXPECT().Get(types.TenantID(7), fileID).Return(nil, nil) }, wantReason: imReasonFileUnavailable, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness oldFiles := store.Files subs := mock_store.NewMockSubsPersistenceInterface(ctrl) files := mock_store.NewMockFilePersistenceInterface(ctrl) store.Subs = subs store.GroupBusiness = &testGroupBusinessStore{} store.Files = files defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Files = oldFiles }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) fileID := types.Uid(101).String() if tc.setupFiles != nil { tc.setupFiles(files, fileID) } req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files", strings.NewReader(fmt.Sprintf(tc.body, fileID))) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "gfile-invalid") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound { t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != tc.wantReason { t.Fatalf("reason: got %q, want %q; body=%s", resp.Reason, tc.wantReason, rec.Body.String()) } }) } } func TestServeIMGroupQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-2")). Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs }() creatorSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, } group := &types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: map[string]any{"name": "Alpha", "version": 1}, } subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(creatorSub, nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(nil, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } created := decodeIMHTTPResponse(t, rec).Data.(map[string]any) token := created["qr_token"].(string) if token == "" { t.Fatalf("unexpected group qr response: %#v", created) } resolveReq := httptest.NewRequest(http.MethodPost, "/v1/im/qrcodes/resolve", bytes.NewBufferString(`{"token":"`+token+`","purpose":"join_group"}`)) resolveReq.Header.Set("Authorization", "Bearer token-2") resolveRec := httptest.NewRecorder() serveIMQRCodesResolve(resolveRec, resolveReq) if resolveRec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", resolveRec.Code, http.StatusOK, resolveRec.Body.String()) } resolved := decodeIMHTTPResponse(t, resolveRec).Data.(map[string]any) groupPayload := resolved["group"].(map[string]any) if resolved["status"] != "can_request" || groupPayload["name"] != "Alpha" { t.Fatalf("unexpected group qr resolve response: %#v", resolved) } } func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness oldMessages := store.Messages mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) mockStore.EXPECT().GetUidString().Return("ann-1") mockStore.EXPECT().GetUidString().Return("join-1") store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs store.Messages = messages groupStore := &testGroupBusinessStore{} store.GroupBusiness = groupStore defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs store.Messages = oldMessages store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(2) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(nil, nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{TenantID: 7, SeqId: 10}, nil) messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != "grpAlpha" || msg.From != types.Uid(42).String() || msg.SeqId != 11 { t.Fatalf("unexpected announcement message: %#v", msg) } if msg.Head["mime"] != "application/json" || msg.Head["x-im-type"] != "text" || msg.Head["x-im-client-mid"] != "group_announcement_ann_ann-1" { t.Fatalf("unexpected announcement message head: %#v", msg.Head) } if msg.Content.(map[string]any)["text"] != "群公告\n公告" { t.Fatalf("unexpected announcement message content: %#v", msg.Content) } return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: true}, nil }) annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`)) annReq.Header.Set("Authorization", "Bearer token-1") annRec := httptest.NewRecorder() serveIMGroupResource(annRec, annReq) if annRec.Code != http.StatusOK { t.Fatalf("announcement status: got %d body=%s", annRec.Code, annRec.Body.String()) } if data := decodeIMHTTPResponse(t, annRec).Data.(map[string]any); data["content"] != "公告" { t.Fatalf("unexpected announcement response: %#v", data) } govReq := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/governance", bytes.NewBufferString(`{"join_approval":true,"version":1}`)) govReq.Header.Set("Authorization", "Bearer token-1") govRec := httptest.NewRecorder() serveIMGroupResource(govRec, govReq) if govRec.Code != http.StatusOK { t.Fatalf("governance status: got %d body=%s", govRec.Code, govRec.Body.String()) } if data := decodeIMHTTPResponse(t, govRec).Data.(map[string]any); data["join_approval"] != true { t.Fatalf("unexpected governance response: %#v", data) } joinReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/join-requests", bytes.NewBufferString(`{"message":"申请"}`)) joinReq.Header.Set("Authorization", "Bearer token-1") joinRec := httptest.NewRecorder() serveIMGroupResource(joinRec, joinReq) if joinRec.Code != http.StatusOK { t.Fatalf("join request status: got %d body=%s", joinRec.Code, joinRec.Body.String()) } if data := decodeIMHTTPResponse(t, joinRec).Data.(map[string]any); data["status"] != "pending" { t.Fatalf("unexpected join request response: %#v", data) } } func TestServeIMGroupAnnouncementLatestIncludesEditPermission(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness oldSubs := store.Subs mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) store.Store = mockStore subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs store.GroupBusiness = &testGroupBusinessStore{} defer func() { store.Store = oldStore store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/latest", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("latest announcement status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) permissions := data["permissions"].(map[string]any) if data["topic"] != "grpAlpha" || data["announcement_id"] != "" || permissions["can_edit"] != true { t.Fatalf("unexpected latest announcement response: %#v", data) } } func TestServeIMGroupAnnouncementGetUsesPathID(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness oldSubs := store.Subs mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) store.Store = mockStore subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs store.GroupBusiness = &testGroupBusinessStore{announcements: map[string]*types.GroupAnnouncement{ "ann_old": {AnnouncementID: "ann_old", Topic: "grpAlpha", AuthorUserID: types.Uid(42), Content: "旧公告", Status: types.GroupAnnouncementStatusSuperseded, Version: 1, PublishedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}, "ann_new": {AnnouncementID: "ann_new", Topic: "grpAlpha", AuthorUserID: types.Uid(42), Content: "新公告", Status: types.GroupAnnouncementStatusCurrent, Version: 2, PublishedAt: time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)}, }} defer func() { store.Store = oldStore store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/ann_old", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("announcement get status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["announcement_id"] != "ann_old" || data["content"] != "旧公告" { t.Fatalf("announcement get ignored path id: %#v", data) } } func TestServeIMGroupAnnouncementGetMissingReturnsNotFound(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldGroupBusiness := store.GroupBusiness oldSubs := store.Subs subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs store.GroupBusiness = &testGroupBusinessStore{} defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/ann_missing", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("announcement missing status: got %d body=%s", rec.Code, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonResourceNotFound { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonResourceNotFound) } } func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness oldSecurity := store.Security mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs groupStore := &testGroupBusinessStore{} store.GroupBusiness = groupStore security := &testSecurityStore{} store.Security = security defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Security = oldSecurity }() owner := types.Uid(42) target := types.Uid(43) ownerSub := &types.Subscription{TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} member := types.Subscription{TenantID: 7, User: target.UserId(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} members := []types.Subscription{*ownerSub, member} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3) topics.EXPECT().GetUsers(types.TenantID(7), "grpAlpha", gomock.Any()).Return(members, nil).Times(2) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil) adminReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/admins/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"version":1}`)) adminReq.Header.Set("Authorization", "Bearer token-1") adminRec := httptest.NewRecorder() serveIMGroupResource(adminRec, adminReq) if adminRec.Code != http.StatusOK { t.Fatalf("admin status: got %d body=%s", adminRec.Code, adminRec.Body.String()) } if !groupStore.updatedRoleMode.IsAdmin() || groupStore.updatedRoleMode.IsOwner() { t.Fatalf("admin role mode should grant admin without owner: %s", groupStore.updatedRoleMode.String()) } muteReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"reason":"test","version":1}`)) muteReq.Header.Set("Authorization", "Bearer token-1") muteRec := httptest.NewRecorder() serveIMGroupResource(muteRec, muteReq) if muteRec.Code != http.StatusOK { t.Fatalf("mute status: got %d body=%s", muteRec.Code, muteRec.Body.String()) } transferReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/owner-transfer", bytes.NewBufferString(`{"target_user_id":"`+target.UserId()+`","version":1}`)) transferReq.Header.Set("Authorization", "Bearer token-1") transferReq.Header.Set("X-Action-Token", "action.1") transferRec := httptest.NewRecorder() serveIMGroupResource(transferRec, transferReq) if transferRec.Code != http.StatusOK { t.Fatalf("transfer status: got %d body=%s", transferRec.Code, transferRec.Body.String()) } if data := decodeIMHTTPResponse(t, transferRec).Data.(map[string]any); data["owner"] != target.UserId() { t.Fatalf("unexpected transfer response: %#v", data) } if security.consumedAction != "group_owner_transfer" || security.consumedTarget != "group_owner_transfer" { t.Fatalf("owner transfer consumed wrong action token scope: action=%q target=%q", security.consumedAction, security.consumedTarget) } } func TestServeIMGroupDissolveConsumesActionToken(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldGroupBusiness := store.GroupBusiness oldSecurity := store.Security mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldSubs := store.Subs subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs groupStore := &testGroupBusinessStore{} store.GroupBusiness = groupStore security := &testSecurityStore{} store.Security = security defer func() { store.Store = oldStore store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness store.Security = oldSecurity }() ownerSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, } subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(ownerSub, nil) req := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"reason":"结束项目"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("X-Action-Token", "action.1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("dissolve status: got %d body=%s", rec.Code, rec.Body.String()) } if security.consumedAction != "group_dissolve" || security.consumedTarget != "group_dissolve" { t.Fatalf("dissolve consumed wrong action token scope: action=%q target=%q", security.consumedAction, security.consumedTarget) } if groupStore.settings == nil || groupStore.settings.GroupStatus != "dissolved" { t.Fatalf("group not dissolved: %#v", groupStore.settings) } } func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) favorites := &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_1", UserID: 42, ItemType: "text", SourceTopic: "grpAlpha", SourceSeq: 3, Snapshot: map[string]any{"text": "hello"}, Availability: "available", CollectedAt: now, }}} store.Favorites = favorites subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Store = oldStore store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil).Times(2) messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{ TenantID: 7, Topic: "grpAlpha", SeqId: 3, From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: now, }, }, nil).Times(2) req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavorites(rec, req) if rec.Code != http.StatusOK { t.Fatalf("list status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_1" { t.Fatalf("unexpected favorites list: %#v", data) } detailReq := httptest.NewRequest(http.MethodGet, "/v1/im/favorites/fav_1", nil) detailReq.Header.Set("Authorization", "Bearer token-1") detailRec := httptest.NewRecorder() serveIMFavoriteResource(detailRec, detailReq) if detailRec.Code != http.StatusOK { t.Fatalf("detail status: got %d body=%s", detailRec.Code, detailRec.Body.String()) } deleteReq := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/batch-delete", bytes.NewBufferString(`{"favorite_ids":["fav_1"]}`)) deleteReq.Header.Set("Authorization", "Bearer token-1") deleteRec := httptest.NewRecorder() serveIMFavoriteResource(deleteRec, deleteReq) if deleteRec.Code != http.StatusOK || len(favorites.deleted) != 1 { t.Fatalf("delete status: got %d body=%s deleted=%#v", deleteRec.Code, deleteRec.Body.String(), favorites.deleted) } } func TestServeIMFavoritesListSupportsRefOnlyAttachment(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC) store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_ref", UserID: 42, ItemType: "file", SourceTopic: "grpAlpha", SourceSeq: 8, Availability: "available", CollectedAt: now, }}} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(8)).Return(&types.Message{ TenantID: 7, Topic: "grpAlpha", SeqId: 8, From: types.Uid(42).String(), Head: types.KVMap{"type": "file"}, Content: map[string]any{ "txt": " ", "fmt": []any{map[string]any{"at": 0, "len": 1, "key": 0}}, "ent": []any{map[string]any{"tp": "EX", "data": map[string]any{ "mime": "application/pdf", "name": "large.pdf", "ref": "https://oss.example/large.pdf", }}}, }, ObjHeader: types.ObjHeader{CreatedAt: now}, }, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=all&limit=1", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavorites(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if items := data["items"].([]any); len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_ref" { t.Fatalf("unexpected ref-only favorite response: %#v", data) } } func TestServeIMFavoritesListKeepsLegacySeedSnapshots(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 9, 9, 9, 0, 0, 0, time.UTC) store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_seed_19179719768_text", UserID: 42, ItemType: "text", SourceTopic: "favSeed19179719768", SourceSeq: 7001, Availability: "available", CollectedAt: now, Snapshot: map[string]any{"text": "seed favorite"}, }}} store.Subs = mock_store.NewMockSubsPersistenceInterface(ctrl) store.Messages = nil defer func() { store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }() req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=all", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavorites(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_seed_19179719768_text" { t.Fatalf("unexpected legacy seed favorite response: %#v", data) } } func TestNormalizeIMUserVisibleTopicSelf(t *testing.T) { uid := types.Uid(42) got, ok := normalizeIMUserVisibleTopic(uid, "slf") if !ok || got != uid.SlfName() { t.Fatalf("normalize slf: got=%q ok=%v want=%q", got, ok, uid.SlfName()) } } func TestMergeGroupSettingsPayloadUsesSettingsVersion(t *testing.T) { payload := map[string]any{"version": 7} mergeGroupSettingsPayload(payload, &types.GroupSettings{Version: 2}) if payload["version"] != 2 { t.Fatalf("settings version was not used: %#v", payload) } } func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("new-fav") oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_existing", UserID: 42, ItemType: "text", SourceTopic: "grpAlpha", SourceSeq: 3, Snapshot: map[string]any{"text": "hello"}, Availability: "available", CollectedAt: now, }}} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{ TenantID: 7, Topic: "grpAlpha", SeqId: 3, From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: now, }, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"grpAlpha","seq":"3"}]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavorites(rec, req) if rec.Code != http.StatusOK { t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["created_count"].(float64) != 0 || data["existing_count"].(float64) != 1 { t.Fatalf("unexpected favorite counts: %#v", data) } } func TestServeIMFavoriteBatchCreateAcceptsNumericSeq(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("new-fav") oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) favorites := &testFavoritesStore{} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Favorites = favorites store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{ TenantID: 7, Topic: "grpAlpha", SeqId: 3, From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: now, }, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/batch", bytes.NewBufferString(`{"messages":[{"topic":"grpAlpha","seq":3}]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavoriteResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["created_count"].(float64) != 1 || data["existing_count"].(float64) != 0 { t.Fatalf("unexpected favorite counts: %#v", data) } if favorites.created == nil || favorites.created.SourceSeq != 3 { t.Fatalf("favorite source seq was not saved: %#v", favorites.created) } } func TestFavoriteItemIdempotencyKeyIncludesMessageIdentity(t *testing.T) { first := favoriteItemIdempotencyKey("same-key", "grpAlpha", 1) second := favoriteItemIdempotencyKey("same-key", "grpAlpha", 2) if first == "" || second == "" || first == second { t.Fatalf("favorite item idempotency key must be stable per source message: %q %q", first, second) } if favoriteItemIdempotencyKey("", "grpAlpha", 1) != "" { t.Fatal("empty request idempotency key should stay empty") } } func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages store.Favorites = &testFavoritesStore{} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }) subs.EXPECT().Get(types.TenantID(7), "grpSecret", types.Uid(42), false).Return(nil, types.ErrNotFound) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"grpSecret","seq":"1"}]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavorites(rec, req) if rec.Code != http.StatusOK { t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["created_count"].(float64) != 0 || data["existing_count"].(float64) != 0 { t.Fatalf("unexpected favorite counts: %#v", data) } failed := data["failed"].([]any) if len(failed) != 1 || failed[0].(map[string]any)["reason"] != "NOT_FOUND" { t.Fatalf("unexpected failed response: %#v", data) } } func TestServeIMFavoriteBatchCreateNormalizesP2PUserTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("new-fav") actor := types.Uid(42) peer := types.Uid(43) normalizedTopic := actor.P2PName(peer) oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages favorites := &testFavoritesStore{} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Favorites = favorites store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }) now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{ TenantID: 7, User: actor.String(), Topic: normalizedTopic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), normalizedTopic, int64(3)).Return(&types.Message{ TenantID: 7, Topic: normalizedTopic, SeqId: 3, From: peer.String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: now, }, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"`+peer.UserId()+`","seq":"3"}]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavorites(rec, req) if rec.Code != http.StatusOK { t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["created_count"].(float64) != 1 || data["existing_count"].(float64) != 0 { t.Fatalf("unexpected favorite counts: %#v", data) } if favorites.created == nil || favorites.created.SourceTopic != normalizedTopic { t.Fatalf("favorite source topic was not normalized: %#v", favorites.created) } snapshot := favorites.created.Snapshot.(map[string]any) if snapshot["from"] != peer.UserId() { t.Fatalf("favorite snapshot from must use full user_id: %#v", favorites.created.Snapshot) } } func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) mockStore.EXPECT().GetUidString().Return("comment-mid") store.Store = mockStore oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_1", UserID: 42, ItemType: "text", SourceTopic: "grpAlpha", SourceSeq: 3, Snapshot: map[string]any{"head": map[string]any{"type": "text"}, "content": map[string]any{"text": "hello"}, "text": "hello"}, Availability: "available", CollectedAt: now, }}} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Store = oldStore store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpBeta", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpBeta", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) gomock.InOrder( messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != "grpBeta" || msg.Head["x-im-type"] != "text" || msg.Content.(map[string]any)["text"] != "FYI" { t.Fatalf("unexpected forward comment message: %#v", msg) } return &types.MessageSaveResult{SeqId: 10, MarkedReadBySender: true}, nil }), messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != "grpBeta" || msg.From != types.Uid(42).String() { t.Fatalf("unexpected forwarded message: %#v", msg) } if msg.Head["x-im-forwarded"] != true { t.Fatalf("forward marker not set: %#v", msg.Head) } content := msg.Content.(map[string]any) if content["text"] != "hello" || content["comment"] != nil || content["snapshot"] != nil { t.Fatalf("unexpected forward content: %#v", content) } return &types.MessageSaveResult{SeqId: 11, MarkedReadBySender: true}, nil }), ) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpBeta"],"comment":"FYI"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavoriteResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any) if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 || got["comment_seq"].(float64) != 10 { t.Fatalf("unexpected forward response: %#v", got) } } func TestServeIMFavoriteForwardRejectsUnauthorizedTarget(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_1", UserID: 42, ItemType: "text", SourceTopic: "grpAlpha", SourceSeq: 3, Snapshot: map[string]any{"text": "hello"}, Availability: "available", CollectedAt: now, }}} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }) subs.EXPECT().Get(types.TenantID(7), "grpDenied", types.Uid(42), false).Return(nil, types.ErrPermissionDenied) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpDenied"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavoriteResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any) if got := items[0].(map[string]any); got["status"] != "failed" || got["reason"] != imReasonPermissionDenied { t.Fatalf("unexpected forward response: %#v", got) } } func TestServeIMMessageForwardSeparateSavesMessages(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("mid-1") oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Subs = oldSubs store.Messages = oldMessages }) subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpTarget", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(3)).Return(&types.Message{ TenantID: 7, Topic: "grpSource", SeqId: 3, From: types.Uid(99).String(), Head: types.KVMap{"x-im-type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), }, }, nil) messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != "grpTarget" || msg.From != types.Uid(42).String() { t.Fatalf("unexpected forwarded message: %#v", msg) } if msg.Head["x-im-forwarded"] != true || msg.Head["x-im-client-mid"] != "fwd_mid-1" { t.Fatalf("forward metadata not set: %#v", msg.Head) } if msg.Content.(map[string]any)["text"] != "hello" { t.Fatalf("unexpected content: %#v", msg.Content) } return &types.MessageSaveResult{SeqId: 21, MarkedReadBySender: true}, nil }) req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":"3"}],"target_topics":["grpTarget"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageForward(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("unexpected forward response: %#v", data) } got := items[0].(map[string]any) if got["status"] != "sent" || got["seq"].(float64) != 21 || got["client_mid"] != "fwd_mid-1" { t.Fatalf("unexpected forward item: %#v", got) } if failed := data["failed"].([]any); len(failed) != 0 { t.Fatalf("unexpected failed items: %#v", failed) } } func TestServeIMMessageForwardSeparateSendsCommentAsTextMessage(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("comment-mid") mockStore.EXPECT().GetUidString().Return("mid-1") oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Subs = oldSubs store.Messages = oldMessages }) subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpTarget", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(3)).Return(&types.Message{ TenantID: 7, Topic: "grpSource", SeqId: 3, From: types.Uid(99).String(), Head: types.KVMap{"x-im-type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}, ObjHeader: types.ObjHeader{ CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), }, }, nil) gomock.InOrder( messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Head["x-im-type"] != "text" || msg.Content.(map[string]any)["text"] != "FYI" { t.Fatalf("unexpected comment message: %#v", msg) } return &types.MessageSaveResult{SeqId: 20, MarkedReadBySender: true}, nil }), messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Head["x-im-type"] != "image" { t.Fatalf("forwarded message type changed: %#v", msg.Head) } content := msg.Content.(map[string]any) if content["file_id"] != "file-1" || content["snapshot"] != nil || content["comment"] != nil { t.Fatalf("forwarded message content should stay original: %#v", content) } return &types.MessageSaveResult{SeqId: 21, MarkedReadBySender: true}, nil }), ) req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":"3"}],"target_topics":["grpTarget"],"comment":"FYI"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageForward(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("unexpected forward response: %#v", data) } got := items[0].(map[string]any) if got["status"] != "sent" || got["seq"].(float64) != 21 || got["comment_seq"].(float64) != 20 { t.Fatalf("unexpected forward item: %#v", got) } } func TestServeIMMessageForwardAcceptsNumericSeqAndUserTarget(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("mid-2") oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Subs = oldSubs store.Messages = oldMessages }) targetUser := types.Uid(99) targetTopic := types.Uid(42).P2PName(targetUser) subs.EXPECT().Get(types.TenantID(7), targetTopic, types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: targetTopic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(60)).Return(&types.Message{ TenantID: 7, Topic: "grpSource", SeqId: 60, From: targetUser.String(), Head: types.KVMap{"x-im-type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), }, }, nil) messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != targetTopic { t.Fatalf("target topic: got %q, want %q", msg.Topic, targetTopic) } return &types.MessageSaveResult{SeqId: 22, MarkedReadBySender: true}, nil }) req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":60}],"target_topics":["`+targetUser.UserId()+`"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageForward(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("unexpected forward response: %#v", data) } got := items[0].(map[string]any) if got["target_topic"] != targetTopic || got["seq"].(float64) != 22 { t.Fatalf("unexpected forward item: %#v", got) } } func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) mockStore.EXPECT().GetUidString().Return("mid-3") oldKafkaBus := kafkaBus oldPublisher := publishKafkaMessagePersist oldHub := globals.hub oldSubs := store.Subs oldTopics := store.Topics oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) kafkaBus = &kafkaRuntime{} globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 3)} store.Subs = subs store.Topics = topics store.Messages = messages t.Cleanup(func() { kafkaBus = oldKafkaBus publishKafkaMessagePersist = oldPublisher globals.hub = oldHub store.Subs = oldSubs store.Topics = oldTopics store.Messages = oldMessages }) targetUser := types.Uid(99) targetTopic := types.Uid(42).P2PName(targetUser) subs.EXPECT().Get(types.TenantID(7), targetTopic, types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: targetTopic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) topics.EXPECT().Get(types.TenantID(7), targetTopic).Return(&types.Topic{ TenantID: 7, SeqId: 34, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(14)).Return(&types.Message{ TenantID: 7, Topic: "grpSource", SeqId: 14, From: targetUser.String(), Head: types.KVMap{"x-im-type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), }, }, nil) var publishedTopic string var publishedKey string var publishedPayload kafkaMessagePersistPayload publishKafkaMessagePersist = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error { publishedTopic = topicName publishedKey = key if err := json.Unmarshal(envelope.Payload, &publishedPayload); err != nil { t.Fatalf("failed to unmarshal kafka payload: %v", err) } return nil } req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":14}],"target_topics":["`+targetUser.UserId()+`"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageForward(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("unexpected forward response: %#v", data) } got := items[0].(map[string]any) if got["target_topic"] != targetTopic || got["seq"].(float64) != 35 { t.Fatalf("unexpected forward item: %#v", got) } if failed := data["failed"].([]any); len(failed) != 0 { t.Fatalf("unexpected failed items: %#v", failed) } if publishedTopic != "message_persist" || publishedKey != targetTopic { t.Fatalf("unexpected kafka publish topic=%q key=%q", publishedTopic, publishedKey) } if publishedPayload.Message.Topic != targetTopic || publishedPayload.Message.SeqId != 35 { t.Fatalf("unexpected kafka message: %#v", publishedPayload.Message) } select { case delivered := <-globals.hub.routeSrv: if delivered.RcptTo != targetTopic || delivered.Data == nil || delivered.Data.SeqId != 35 { t.Fatalf("unexpected broadcast message: %#v", delivered) } default: t.Fatal("expected forwarded message to be broadcast") } presByUser := make(map[string]*MsgServerPres) for range 2 { select { case delivered := <-globals.hub.routeSrv: if delivered.Pres == nil { t.Fatalf("unexpected non-presence message: %#v", delivered) } presByUser[delivered.RcptTo] = delivered.Pres default: t.Fatal("expected forwarded message presence notifications") } } if pres := presByUser[types.Uid(42).UserId()]; pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != targetUser.UserId() || pres.SeqId != 35 || pres.AcsActor != "" || pres.SkipTopic != targetTopic { t.Fatalf("unexpected sender presence: %#v", pres) } if pres := presByUser[targetUser.UserId()]; pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != types.Uid(42).UserId() || pres.SeqId != 35 || pres.AcsActor != types.Uid(42).UserId() || pres.SkipTopic != targetTopic { t.Fatalf("unexpected receiver presence: %#v", pres) } } func TestServeIMMessageForwardKafkaPublishUnknownReturnsPending(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldKafkaBus := kafkaBus oldPublisher := publishKafkaMessagePersist oldHub := globals.hub oldSubs := store.Subs oldTopics := store.Topics oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) kafkaBus = &kafkaRuntime{} globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 3)} store.Subs = subs store.Topics = topics store.Messages = messages t.Cleanup(func() { kafkaBus = oldKafkaBus publishKafkaMessagePersist = oldPublisher globals.hub = oldHub store.Subs = oldSubs store.Topics = oldTopics store.Messages = oldMessages }) targetUser := types.Uid(99) targetTopic := types.Uid(42).P2PName(targetUser) subs.EXPECT().Get(types.TenantID(7), targetTopic, types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: targetTopic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) topics.EXPECT().Get(types.TenantID(7), targetTopic).Return(&types.Topic{ TenantID: 7, SeqId: 40, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(14)).Return(&types.Message{ TenantID: 7, Topic: "grpSource", SeqId: 14, From: targetUser.String(), Head: types.KVMap{"x-im-type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), }, }, nil) publishKafkaMessagePersist = func(_ context.Context, _, _ string, _ kafkaEnvelope) error { return context.DeadlineExceeded } req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":14}],"target_topics":["`+targetUser.UserId()+`"]}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "idem-forward-1") rec := httptest.NewRecorder() serveIMMessageForward(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("unexpected forward response: %#v", data) } got := items[0].(map[string]any) if got["status"] != "pending" || got["reason"] != "MESSAGE_PERSIST_PENDING" || got["seq"].(float64) != 41 { t.Fatalf("unexpected pending item: %#v", got) } if got["client_mid"] != "fwd_"+stableIMEventID("forward", "idem-forward-1", "separate", targetTopic, "grpSource", int64(14)) { t.Fatalf("unexpected stable client mid: %#v", got) } if failed := data["failed"].([]any); len(failed) != 0 { t.Fatalf("unexpected failed items: %#v", failed) } select { case delivered := <-globals.hub.routeSrv: t.Fatalf("pending forward should not broadcast immediately: %#v", delivered) default: } } func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) gomock.InOrder( mockStore.EXPECT().GetUidString().Return("bundle-1"), mockStore.EXPECT().GetUidString().Return("mid-3"), ) oldSubs := store.Subs oldMessages := store.Messages oldUsers := store.Users oldTopics := store.Topics subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) users := mock_store.NewMockUsersPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages store.Users = users store.Topics = topics t.Cleanup(func() { store.Subs = oldSubs store.Messages = oldMessages store.Users = oldUsers store.Topics = oldTopics }) subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpTarget", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(60)).Return(&types.Message{ TenantID: 7, Topic: "grpSource", SeqId: 60, From: types.Uid(99).String(), Head: types.KVMap{"x-im-type": "text"}, Content: map[string]any{"text": "hello"}, ObjHeader: types.ObjHeader{ CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), }, }, nil) users.EXPECT().GetAll(types.TenantID(7), types.Uid(99)).Return([]types.User{{ ObjHeader: types.ObjHeader{Id: types.Uid(99).String()}, TenantID: 7, Public: types.KVMap{"display_name": "后端-potter", "avatar_file_id": "avatar-99"}, }}, nil) topics.EXPECT().Get(types.TenantID(7), "grpSource").Return(&types.Topic{Public: map[string]any{"fn": "研发群"}}, nil) messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != "grpTarget" || msg.Head["x-im-type"] != "forward_bundle" { t.Fatalf("unexpected bundle message: %#v", msg) } content := msg.Content.(map[string]any) if content["bundle_id"] != "fbundle_bundle-1" || content["item_count"] != 1 { t.Fatalf("unexpected bundle content: %#v", content) } if content["title"] != "研发群的聊天记录" { t.Fatalf("unexpected bundle title: %#v", content) } bundleItems := content["items"].([]map[string]any) itemUser := bundleItems[0]["from_user"].(map[string]any) if itemUser["user_id"] != types.Uid(99).UserId() || itemUser["display_name"] != "后端-potter" || itemUser["avatar_file_id"] != "avatar-99" { t.Fatalf("unexpected item sender summary: %#v", itemUser) } previewItems := content["preview"].([]map[string]any) previewUser := previewItems[0]["from_user"].(map[string]any) if previewUser["user_id"] != types.Uid(99).UserId() || previewUser["display_name"] != "后端-potter" || previewUser["avatar_file_id"] != "avatar-99" { t.Fatalf("unexpected preview sender summary: %#v", previewUser) } return &types.MessageSaveResult{SeqId: 23, MarkedReadBySender: true}, nil }) req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"bundle","sources":[{"topic":"grpSource","seq":60}],"target_topics":["grpTarget"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageForward(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("unexpected forward response: %#v", data) } got := items[0].(map[string]any) if got["mode"] != "bundle" || got["bundle_id"] != "fbundle_bundle-1" || got["seq"].(float64) != 23 { t.Fatalf("unexpected forward item: %#v", got) } } func TestForwardBundleTitleUsesP2PUsers(t *testing.T) { uid1 := types.Uid(42) uid2 := types.Uid(99) title := forwardBundleTitle(types.TenantID(7), []string{uid1.P2PName(uid2)}, map[types.Uid]map[string]any{ uid1: {"display_name": "Alice"}, uid2: {"display_name": "Bob"}, }) if title != "Alice和Bob的聊天记录" { t.Fatalf("unexpected p2p title: %s", title) } } func TestForwardBundleTitleUsesGroupName(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldTopics := store.Topics topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) store.Topics = topics t.Cleanup(func() { store.Topics = oldTopics }) topics.EXPECT().Get(types.TenantID(7), "grpSource").Return(&types.Topic{Public: map[string]any{"fn": "项目群"}}, nil) title := forwardBundleTitle(types.TenantID(7), []string{"grpSource"}, nil) if title != "项目群的聊天记录" { t.Fatalf("unexpected group title: %s", title) } } func TestServeIMSecurityURLCheck(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldSafety := store.Safety mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(2) store.Store = mockStore store.Safety = &testSafetyStore{} defer func() { store.Store = oldStore store.Safety = oldSafety }() allowReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"https://example.com/a","source":"message"}`)) allowReq.Header.Set("Authorization", "Bearer token-1") allowRec := httptest.NewRecorder() serveIMSecurityURLCheck(allowRec, allowReq) if allowRec.Code != http.StatusOK { t.Fatalf("allow status: got %d body=%s", allowRec.Code, allowRec.Body.String()) } if data := decodeIMHTTPResponse(t, allowRec).Data.(map[string]any); data["decision"] != "allow" { t.Fatalf("unexpected allow response: %#v", data) } blockReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"http://127.0.0.1/admin"}`)) blockReq.Header.Set("Authorization", "Bearer token-1") blockRec := httptest.NewRecorder() serveIMSecurityURLCheck(blockRec, blockReq) if blockRec.Code != http.StatusOK { t.Fatalf("block status: got %d body=%s", blockRec.Code, blockRec.Body.String()) } if data := decodeIMHTTPResponse(t, blockRec).Data.(map[string]any); data["decision"] != "block" || data["risk_challenge_id"] == "" { t.Fatalf("unexpected block response: %#v", data) } } func TestServeIMRiskChallengeConfirm(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSafety := store.Safety oldSecurity := store.Security now := time.Now().UTC().Round(time.Millisecond) store.Safety = &testSafetyStore{riskChallenge: &types.RiskChallenge{ ChallengeID: "risk-1", UserID: 42, Action: "open_url", Level: "high", NoticeCode: "url_high", State: types.RiskChallengeStatePending, ExpiresAt: now.Add(time.Minute), CreatedAt: now, UpdatedAt: now, }} store.Security = &testSecurityStore{} defer func() { store.Safety = oldSafety store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/risk-challenges/risk-1/confirm", bytes.NewBufferString(`{"decision":"continue"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMRiskChallengeConfirm(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["state"] != types.RiskChallengeStateContinued || data["risk_action_token"] == "" { t.Fatalf("unexpected risk confirm response: %#v", data) } if safety := store.Safety.(*testSafetyStore); safety.tenantID != 7 || safety.uid != 42 || safety.decision != "continue" || safety.actionTokenID == "" { t.Fatalf("risk challenge scope mismatch: %#v", safety) } } func TestServeIMRiskChallengeConfirmNotFound(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSafety := store.Safety store.Safety = &testSafetyStore{riskErr: types.ErrNotFound} defer func() { store.Safety = oldSafety }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/risk-challenges/risk_fake/confirm", bytes.NewBufferString(`{"decision":"cancel"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMRiskChallengeConfirm(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status: got %d want %d body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonResourceNotFound { t.Fatalf("reason: got %q want %q", resp.Reason, imReasonResourceNotFound) } } func TestServeIMAbuseReportsAndReasons(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSafety := store.Safety oldUsers := store.Users safety := &testSafetyStore{} store.Safety = safety users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users users.EXPECT().Get(types.TenantID(7), types.Uid(43)).Return(&types.User{}, nil).AnyTimes() defer func() { store.Safety = oldSafety store.Users = oldUsers }() req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=user", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMAbuseReasons(rec, req) if rec.Code != http.StatusOK { t.Fatalf("reasons status: got %d body=%s", rec.Code, rec.Body.String()) } if safety.tenantID != 7 || safety.targetType != "user" { t.Fatalf("abuse reason scope mismatch: %#v", safety) } req = httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=group_member", nil) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMAbuseReasons(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("unsupported reasons status: got %d body=%s", rec.Code, rec.Body.String()) } req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/abuse", bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "abuse-1") rec = httptest.NewRecorder() serveIMAbuseReports(rec, req) if rec.Code != http.StatusOK { t.Fatalf("report status: got %d body=%s", rec.Code, rec.Body.String()) } if safety.tenantID != 7 || safety.idempotency != "abuse-1" || safety.abuseReport == nil || safety.abuseReport.ReporterID != 42 || safety.abuseReport.TargetUserID != 43 || safety.abuseReport.ReasonCode != "fraud" { t.Fatalf("unexpected abuse report: %#v", safety.abuseReport) } } func TestServeIMFeedback(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSafety := store.Safety safety := &testSafetyStore{} store.Safety = safety defer func() { store.Safety = oldSafety }() req := httptest.NewRequest(http.MethodGet, "/v1/im/feedback/config", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFeedbackConfig(rec, req) if rec.Code != http.StatusOK { t.Fatalf("config status: got %d body=%s", rec.Code, rec.Body.String()) } if safety.tenantID != 7 || safety.uid != 42 { t.Fatalf("feedback config scope mismatch: %#v", safety) } req = httptest.NewRequest(http.MethodPost, "/v1/im/feedback", bytes.NewBufferString(`{"category":"bug","content":"页面显示异常","contact":"me@example.com","attachment_file_ids":["file1"],"client_context":{"platform":"android"},"include_diagnostics":true}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "feedback-1") rec = httptest.NewRecorder() serveIMFeedback(rec, req) if rec.Code != http.StatusOK { t.Fatalf("submit status: got %d body=%s", rec.Code, rec.Body.String()) } if safety.tenantID != 7 || safety.idempotency != "feedback-1" || safety.feedback == nil || safety.feedback.UserID != 42 || safety.feedback.Category != "bug" || !safety.feedback.IncludeDiagnostics { t.Fatalf("unexpected feedback: %#v", safety.feedback) } } func TestServeIMMessageSearchContextAndMedia(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Store = oldStore store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}, nil).Times(3) textMsg := types.Message{TenantID: 7, SeqId: 3, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello world"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}} fileMsg := types.Message{TenantID: 7, SeqId: 4, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC)}} messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "hello", "text", "", 50).Return([]types.Message{textMsg}, "", nil) messages.EXPECT().GetAll(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return([]types.Message{textMsg}, nil) messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "", "image", "", 50).Return([]types.Message{fileMsg}, "", nil) searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/search?q=hello&type=text", nil) searchReq.Header.Set("Authorization", "Bearer token-1") searchRec := httptest.NewRecorder() serveIMTopicMessageAction(searchRec, searchReq) if searchRec.Code != http.StatusOK { t.Fatalf("search status: got %d body=%s", searchRec.Code, searchRec.Body.String()) } searchData := decodeIMHTTPResponse(t, searchRec).Data.(map[string]any) searchItems := searchData["items"].([]any) if len(searchItems) != 1 || searchItems[0].(map[string]any)["from"] != types.Uid(42).UserId() { t.Fatalf("search from must use full user_id: %#v", searchData) } searchSnapshot := searchItems[0].(map[string]any)["snapshot"].(map[string]any) if searchSnapshot["from"] != types.Uid(42).UserId() { t.Fatalf("search snapshot from must use full user_id: %#v", searchSnapshot) } contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/3/context", nil) contextReq.Header.Set("Authorization", "Bearer token-1") contextRec := httptest.NewRecorder() serveIMTopicMessageAction(contextRec, contextReq) if contextRec.Code != http.StatusOK { t.Fatalf("context status: got %d body=%s", contextRec.Code, contextRec.Body.String()) } contextData := decodeIMHTTPResponse(t, contextRec).Data.(map[string]any) contextItems := contextData["items"].([]any) if len(contextItems) != 1 || contextItems[0].(map[string]any)["from"] != types.Uid(42).UserId() { t.Fatalf("context from must use full user_id: %#v", contextData) } mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/media?type=image", nil) mediaReq.Header.Set("Authorization", "Bearer token-1") mediaRec := httptest.NewRecorder() serveIMTopicMessageAction(mediaRec, mediaReq) if mediaRec.Code != http.StatusOK { t.Fatalf("media status: got %d body=%s", mediaRec.Code, mediaRec.Body.String()) } mediaData := decodeIMHTTPResponse(t, mediaRec).Data.(map[string]any) mediaItems := mediaData["items"].([]any) if len(mediaItems) != 1 || mediaItems[0].(map[string]any)["from"] != types.Uid(42).UserId() { t.Fatalf("media from must use full user_id: %#v", mediaData) } } func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() actor := types.Uid(42) peer := types.Uid(43) normalizedTopic := actor.P2PName(peer) oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: actor, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Store = oldStore store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{ TenantID: 7, User: actor.String(), Topic: normalizedTopic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil).Times(3) textMsg := types.Message{TenantID: 7, SeqId: 9, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "好的"}} imageMsg := types.Message{TenantID: 7, SeqId: 10, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}} messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "好的", "text", "", 50).Return([]types.Message{textMsg}, "", nil) messages.EXPECT().GetAll(types.TenantID(7), normalizedTopic, actor, gomock.Any()).Return([]types.Message{textMsg}, nil) messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "", "image", "", 50).Return([]types.Message{imageMsg}, "", nil) searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/search?q=%E5%A5%BD%E7%9A%84&type=text", nil) searchReq.Header.Set("Authorization", "Bearer token-1") searchRec := httptest.NewRecorder() serveIMTopicMessageAction(searchRec, searchReq) if searchRec.Code != http.StatusOK { t.Fatalf("search status: got %d body=%s", searchRec.Code, searchRec.Body.String()) } contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/9/context", nil) contextReq.Header.Set("Authorization", "Bearer token-1") contextRec := httptest.NewRecorder() serveIMTopicMessageAction(contextRec, contextReq) if contextRec.Code != http.StatusOK { t.Fatalf("context status: got %d body=%s", contextRec.Code, contextRec.Body.String()) } contextData := decodeIMHTTPResponse(t, contextRec).Data.(map[string]any) if contextData["topic"] != normalizedTopic { t.Fatalf("context topic was not normalized: %#v", contextData) } mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/media?type=image", nil) mediaReq.Header.Set("Authorization", "Bearer token-1") mediaRec := httptest.NewRecorder() serveIMTopicMessageAction(mediaRec, mediaReq) if mediaRec.Code != http.StatusOK { t.Fatalf("media status: got %d body=%s", mediaRec.Code, mediaRec.Body.String()) } } func TestMessageSearchPayloadMatchesUseSnippetRuneOffsets(t *testing.T) { msg := types.Message{ TenantID: 7, Topic: "grpAlpha", SeqId: 1, Version: 2, From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"txt": "你们已成为好友,现在可以开始聊天了。", "fmt": []any{}}, ObjHeader: types.ObjHeader{ CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), }, } payload := messageSearchPayload(msg, "好友") if payload["snippet"] != "你们已成为好友,现在可以开始聊天了。" { t.Fatalf("unexpected snippet: %#v", payload["snippet"]) } matches := payload["matches"].([]map[string]any) if len(matches) != 1 || matches[0]["start"] != 5 || matches[0]["end"] != 7 { t.Fatalf("unexpected matches: %#v", matches) } if payload["version"] != 2 { t.Fatalf("unexpected version: %#v", payload["version"]) } snapshot := payload["snapshot"].(map[string]any) if snapshot["text"] != "你们已成为好友,现在可以开始聊天了。" || snapshot["version"] != 2 { t.Fatalf("unexpected snapshot: %#v", snapshot) } } func TestServeIMTopicCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldSubs := store.Subs oldCalls := store.Calls subs := mock_store.NewMockSubsPersistenceInterface(ctrl) calls := &testCallsStore{items: []types.CallRecord{{ ID: 9, TenantID: 7, CallID: "call-1", Topic: "p2pTopic", SeqID: 12, CallerID: 42, CalleeID: 43, CallType: "video", Status: types.CallStatusFinished, DurationMS: 1200, StartedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC), }}} store.Subs = subs store.Calls = calls defer func() { store.Store = oldStore store.Subs = oldSubs store.Calls = oldCalls }() subs.EXPECT().Get(types.TenantID(7), "p2pTopic", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pTopic/calls?cursor=c1&limit=1", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMTopicMessageAction(rec, req) if rec.Code != http.StatusOK { t.Fatalf("calls status: got %d body=%s", rec.Code, rec.Body.String()) } if !calls.listed || calls.tenant != 7 || calls.user != 42 || calls.topic != "p2pTopic" || calls.cursor != "c1" || calls.limit != 1 { t.Fatalf("calls list scope mismatch: %#v", calls) } var resp imHTTPResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } data := resp.Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("items: got %#v", items) } item := items[0].(map[string]any) if item["call_id"] != "call-1" || item["call_type"] != "video" || item["direction"] != "outgoing" { t.Fatalf("unexpected call item: %#v", item) } } func TestServeIMTopicCallsRejectsUnauthorizedWithoutListing(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldCalls := store.Calls subs := mock_store.NewMockSubsPersistenceInterface(ctrl) calls := &testCallsStore{} store.Subs = subs store.Calls = calls defer func() { store.Subs = oldSubs store.Calls = oldCalls }() subs.EXPECT().Get(types.TenantID(7), "p2pDenied", types.Uid(42), false).Return(nil, types.ErrPermissionDenied) req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pDenied/calls", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMTopicMessageAction(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("calls status: got %d body=%s", rec.Code, rec.Body.String()) } if calls.listed { t.Fatalf("calls were listed despite missing read permission: %#v", calls) } } func TestServeIMCallRTCJoinReturnsAgoraAppIDMode(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-caller", 7, 42) oldSubs := store.Subs oldCalls := store.Calls oldThirdParty := store.ThirdPartyConfig subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topic := types.Uid(42).P2PName(types.Uid(43)) calls := &testCallsStore{items: []types.CallRecord{{ ID: 9, TenantID: 7, CallID: "call-1", Topic: topic, SeqID: 12, CallerID: 42, CalleeID: 43, CallType: "video", Status: types.CallStatusStarted, StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), }}} store.Subs = subs store.Calls = calls store.ThirdPartyConfig = testThirdPartyConfigStore{ "agora_enabled": "1", "agora_auth_mode": "app_id", "agora_app_id": "app123", "agora_channel_prefix": "call_", "agora_uid_mode": "string", "agora_token_ttl_seconds": "3600", } defer func() { store.Subs = oldSubs store.Calls = oldCalls store.ThirdPartyConfig = oldThirdParty }() subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`)) req.Header.Set("Authorization", "Bearer token-caller") rec := httptest.NewRecorder() serveIMCallRTCJoin(rec, req) if rec.Code != http.StatusOK { t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String()) } if !calls.listed || calls.tenant != 7 || calls.user != 42 || calls.topic != topic || calls.limit != 100 { t.Fatalf("calls lookup mismatch: %#v", calls) } resp := decodeIMHTTPResponse(t, rec) data := resp.Data.(map[string]any) if data["provider"] != "agora" || data["auth_mode"] != "app_id" || data["app_id"] != "app123" || data["channel_name"] != "call_call-1" || data["uid"] != types.Uid(42).UserId() || data["token"] != "" { t.Fatalf("unexpected rtc join payload: %#v", data) } } func TestServeIMCallRTCJoinAcceptsP2PAliasTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-caller", 7, 42) oldSubs := store.Subs oldCalls := store.Calls oldThirdParty := store.ThirdPartyConfig subs := mock_store.NewMockSubsPersistenceInterface(ctrl) peer := types.Uid(43) topic := types.Uid(42).P2PName(peer) calls := &testCallsStore{items: []types.CallRecord{{ ID: 9, TenantID: 7, CallID: "call-1", Topic: topic, SeqID: 12, CallerID: 42, CalleeID: peer, CallType: "audio", Status: types.CallStatusStarted, StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), }}} store.Subs = subs store.Calls = calls store.ThirdPartyConfig = testThirdPartyConfigStore{ "agora_enabled": "1", "agora_auth_mode": "app_id", "agora_app_id": "app123", "agora_channel_prefix": "call_", "agora_uid_mode": "string", "agora_token_ttl_seconds": "3600", } defer func() { store.Subs = oldSubs store.Calls = oldCalls store.ThirdPartyConfig = oldThirdParty }() subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+peer.UserId()+`","call_id":"call-1","seq":12,"call_type":"audio","role":"caller"}`)) req.Header.Set("Authorization", "Bearer token-caller") rec := httptest.NewRecorder() serveIMCallRTCJoin(rec, req) if rec.Code != http.StatusOK { t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != topic || calls.topic != topic { t.Fatalf("alias topic was not normalized: data=%#v calls=%#v", data, calls) } } func TestServeIMCallRTCJoinReturnsSameAgoraChannelForCallee(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).AnyTimes() tokenAuth.EXPECT().IsInitialized().Return(true).AnyTimes() tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-caller")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes() tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-callee")). Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes() store.Store = mockStore oldSubs := store.Subs oldCalls := store.Calls oldThirdParty := store.ThirdPartyConfig subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topic := types.Uid(42).P2PName(types.Uid(43)) calls := &testCallsStore{items: []types.CallRecord{{ ID: 9, TenantID: 7, CallID: "call-1", Topic: topic, SeqID: 12, CallerID: 42, CalleeID: 43, CallType: "audio", Status: types.CallStatusStarted, StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), }}} store.Subs = subs store.Calls = calls store.ThirdPartyConfig = testThirdPartyConfigStore{ "agora_enabled": "1", "agora_auth_mode": "app_id", "agora_app_id": "app123", "agora_channel_prefix": "call_", "agora_uid_mode": "string", "agora_token_ttl_seconds": "3600", } defer func() { store.Store = oldStore store.Subs = oldSubs store.Calls = oldCalls store.ThirdPartyConfig = oldThirdParty }() subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(43), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(43).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) callerReq := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"audio","role":"caller"}`)) callerReq.Header.Set("Authorization", "Bearer token-caller") callerRec := httptest.NewRecorder() serveIMCallRTCJoin(callerRec, callerReq) if callerRec.Code != http.StatusOK { t.Fatalf("caller rtc join status: got %d body=%s", callerRec.Code, callerRec.Body.String()) } calleeReq := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"audio","role":"callee"}`)) calleeReq.Header.Set("Authorization", "Bearer token-callee") calleeRec := httptest.NewRecorder() serveIMCallRTCJoin(calleeRec, calleeReq) if calleeRec.Code != http.StatusOK { t.Fatalf("callee rtc join status: got %d body=%s", calleeRec.Code, calleeRec.Body.String()) } callerData := decodeIMHTTPResponse(t, callerRec).Data.(map[string]any) calleeData := decodeIMHTTPResponse(t, calleeRec).Data.(map[string]any) if callerData["channel_name"] != calleeData["channel_name"] { t.Fatalf("channel mismatch: caller=%#v callee=%#v", callerData, calleeData) } if callerData["uid"] == calleeData["uid"] || calleeData["uid"] != types.Uid(43).UserId() { t.Fatalf("unexpected rtc uid: caller=%#v callee=%#v", callerData, calleeData) } if callerData["token"] != "" || calleeData["token"] != "" { t.Fatalf("app_id mode token must be empty: caller=%#v callee=%#v", callerData, calleeData) } } func TestServeIMCallRTCJoinReturnsAgoraTokenMode(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldCalls := store.Calls oldThirdParty := store.ThirdPartyConfig subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topic := types.Uid(42).P2PName(types.Uid(43)) calls := &testCallsStore{items: []types.CallRecord{{ ID: 9, TenantID: 7, CallID: "call-1", Topic: topic, SeqID: 12, CallerID: 42, CalleeID: 43, CallType: "video", Status: types.CallStatusStarted, StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), }}} store.Subs = subs store.Calls = calls store.ThirdPartyConfig = testThirdPartyConfigStore{ "agora_enabled": "1", "agora_auth_mode": "token", "agora_app_id": "00000000000000000000000000000000", "agora_app_certificate": "11111111111111111111111111111111", "agora_channel_prefix": "call_", "agora_uid_mode": "string", "agora_token_ttl_seconds": "3600", } defer func() { store.Subs = oldSubs store.Calls = oldCalls store.ThirdPartyConfig = oldThirdParty }() subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMCallRTCJoin(rec, req) if rec.Code != http.StatusOK { t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["auth_mode"] != "token" || data["token"] == "" || data["token_expires_at"] == nil { t.Fatalf("unexpected token payload: %#v", data) } if _, found := data["app_certificate"]; found { t.Fatalf("app certificate must not be returned: %#v", data) } } func TestServeIMCallRTCJoinRejectsDisabledAgora(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldThirdParty := store.ThirdPartyConfig store.ThirdPartyConfig = testThirdPartyConfigStore{"agora_enabled": "0"} defer func() { store.ThirdPartyConfig = oldThirdParty }() topic := types.Uid(42).P2PName(types.Uid(43)) req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMCallRTCJoin(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonAgoraDisabled { t.Fatalf("reason: got %q", resp.Reason) } } func TestServeIMMeQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 1, AuthLevel: auth.LevelAuth}, nil, nil) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-2")). Return(&auth.Rec{TenantID: 7, Uid: 2, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldUsers := store.Users oldRelations := store.Relations oldProfiles := store.Profiles users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users owner := types.Uid(1) viewer := types.Uid(2) users.EXPECT().Get(types.TenantID(7), owner).Return(&types.User{ ObjHeader: types.ObjHeader{Id: owner.String()}, TenantID: 7, Public: map[string]any{"display_name": "Owner"}, }, nil) users.EXPECT().Get(types.TenantID(7), owner).Return(&types.User{ ObjHeader: types.ObjHeader{Id: owner.String()}, TenantID: 7, Public: map[string]any{"display_name": "Owner"}, }, nil) store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: viewer, PeerID: owner, Topic: viewer.P2PName(owner), Relationship: types.RelationshipNone, CanSend: false, }} store.Profiles = &testProfilesStore{} defer func() { store.Store = oldStore store.Users = oldUsers store.Relations = oldRelations store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPost, "/v1/im/me/qrcodes", bytes.NewBufferString(`{"purpose":"add_friend","expires_in_seconds":60}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeQRCodes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } created := decodeIMHTTPResponse(t, rec).Data.(map[string]any) token := created["qr_token"].(string) if token == "" || created["qr_payload"] == "" { t.Fatalf("unexpected qr create response: %#v", created) } resolveReq := httptest.NewRequest(http.MethodPost, "/v1/im/qrcodes/resolve", bytes.NewBufferString(`{"token":"`+token+`","purpose":"add_friend"}`)) resolveReq.Header.Set("Authorization", "Bearer token-2") resolveRec := httptest.NewRecorder() serveIMQRCodesResolve(resolveRec, resolveReq) if resolveRec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", resolveRec.Code, http.StatusOK, resolveRec.Body.String()) } resolved := decodeIMHTTPResponse(t, resolveRec).Data.(map[string]any) user := resolved["user"].(map[string]any) if resolved["purpose"] != "add_friend" || user["action"] != "add" || user["display_name"] != "Owner" { t.Fatalf("unexpected qr resolve response: %#v", resolved) } } func TestServeIMMessageRecall(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpRecall", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: "42", Topic: "grpRecall", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(9)).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: "grpRecall", From: types.Uid(42).String(), Version: 1, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, }, nil) messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(9), types.Uid(42), 1, gomock.Any()).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: "grpRecall", From: "42", Version: 2, RecalledAt: ptrTime(time.Now().UTC()), RecalledBy: "42", ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/9/recall", bytes.NewBufferString(`{}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageRecall(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "recalled" || data["version"].(float64) != 2 { t.Fatalf("unexpected recall response: %#v", data) } } func TestServeIMMessageRecallNormalizesP2PUserTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() actor := types.Uid(42) peer := types.Uid(43) normalizedTopic := actor.P2PName(peer) expectIMBearer(t, ctrl, "token-1", 7, actor) oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{ TenantID: 7, User: actor.String(), Topic: normalizedTopic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().GetOne(types.TenantID(7), normalizedTopic, int64(9)).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: normalizedTopic, From: actor.String(), Version: 1, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, }, nil) messages.EXPECT().Recall(types.TenantID(7), normalizedTopic, int64(9), actor, 1, gomock.Any()).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: normalizedTopic, From: actor.String(), Version: 2, RecalledAt: ptrTime(time.Now().UTC()), RecalledBy: actor.String(), ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/"+peer.UserId()+"/messages/9/recall", bytes.NewBufferString(`{"version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageRecall(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != normalizedTopic { t.Fatalf("expected normalized topic %q, got %#v", normalizedTopic, data) } } func TestServeIMMessageRevision(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpEdit", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: "42", Topic: "grpEdit", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", int64(3)).Return(&types.Message{ TenantID: 7, SeqId: 3, Topic: "grpEdit", From: types.Uid(42).String(), Version: 1, Head: types.KVMap{"type": "text"}, Content: "old", ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, }, nil) messages.EXPECT().Edit(types.TenantID(7), "grpEdit", int64(3), types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{ TenantID: 7, SeqId: 3, Topic: "grpEdit", From: "42", Version: 2, Head: types.KVMap{"type": "text"}, Content: "new", ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpEdit/messages/3/revisions", bytes.NewBufferString(`{"version":1,"content":"new"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageRevision(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "edited" || data["version"].(float64) != 2 || data["content"] != "new" { t.Fatalf("unexpected revision response: %#v", data) } } func TestServeIMConversationContext(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) topic := types.Uid(1).P2PName(peer) store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: topic, Relationship: types.RelationshipFriend, CanSend: true, }} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+topic+"/context", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationContext(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipFriend || data["can_send"] != true { t.Fatalf("unexpected context response: %#v", data) } } func TestServeIMConversationContextAcceptsUserTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) topic := types.Uid(1).P2PName(peer) store.Relations = &testRelationsStore{} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/context", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationContext(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != topic || data["peer_user_id"] != peer.UserId() { t.Fatalf("unexpected context response: %#v", data) } } func TestServeIMConversationContextAcceptsFriendListTopicFallback(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) legacyTopic := types.Uid(2).P2PName(types.Uid(3)) store.Relations = &testRelationsStore{friends: []types.FriendListItem{{ Friendship: types.Friendship{ TenantID: 7, UserID: types.Uid(1), PeerID: peer, Status: types.RelationshipFriend, Topic: legacyTopic, }, }}} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+legacyTopic+"/context", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationContext(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != legacyTopic || data["peer_user_id"] != peer.UserId() { t.Fatalf("unexpected context response: %#v", data) } } func TestNormalizeP2PConversationTopicParsesTinodeTopic(t *testing.T) { user := types.Uid(1) peer := types.Uid(2) topic := user.P2PName(peer) normalized, gotPeer := normalizeP2PConversationTopic(topic, user) if normalized != topic || gotPeer != peer { t.Fatalf("normalize p2p: got topic=%q peer=%s, want topic=%q peer=%s", normalized, gotPeer.UserId(), topic, peer.UserId()) } } func TestServeIMP2PConversation(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldConversations := store.Conversations oldRelations := store.Relations peer := types.Uid(2) conversations := &testConversationsStore{} store.Conversations = conversations store.Relations = &testRelationsStore{} defer func() { store.Conversations = oldConversations store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodPost, "/v1/im/conversations/p2p", strings.NewReader(`{"peer_user_id":"`+peer.UserId()+`"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMP2PConversation(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != types.Uid(1).P2PName(peer) || data["can_send"] != true { t.Fatalf("unexpected p2p response: %#v topic=%q", data, conversations.topic) } } func TestServeIMConversationSettings(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldConversations := store.Conversations topic := types.Uid(1).P2PName(types.Uid(2)) conversations := &testConversationsStore{} store.Conversations = conversations defer func() { store.Conversations = oldConversations }() req := httptest.NewRequest(http.MethodPatch, "/v1/im/conversations/"+topic+"/settings", strings.NewReader(`{"pinned":true,"mute_notifications":true,"remark":"同事","version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationContext(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["pinned"] != true || data["mute_notifications"] != true || data["remark"] != "同事" || data["version"].(float64) != 2 { t.Fatalf("unexpected settings response: %#v", data) } if conversations.settingsPatch.Muted == nil || *conversations.settingsPatch.Muted != true { t.Fatalf("mute_notifications was not mapped to Muted patch: %#v", conversations.settingsPatch) } } func TestServeIMConversationSettingsNormalizesUserTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldConversations := store.Conversations peer := types.Uid(2) topic := types.Uid(1).P2PName(peer) conversations := &testConversationsStore{} store.Conversations = conversations defer func() { store.Conversations = oldConversations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/settings", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationContext(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if conversations.topic != topic { t.Fatalf("settings topic: got %q, want %q", conversations.topic, topic) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != topic { t.Fatalf("unexpected settings response: %#v", data) } } func TestServeIMTopicDraftPutAndGet(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldMaxMessageSize := globals.maxMessageSize globals.maxMessageSize = 1024 defer func() { globals.maxMessageSize = oldMaxMessageSize }() oldConversations := store.Conversations topic := "grpDraft" conversations := &testConversationsStore{} store.Conversations = conversations defer func() { store.Conversations = oldConversations }() req := httptest.NewRequest(http.MethodPut, "/v1/im/topics/"+topic+"/draft", strings.NewReader(`{"content":"未发送草稿","entities":[{"type":"mention"}],"version":0}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMTopicMessageAction(rec, req) if rec.Code != http.StatusOK { t.Fatalf("put status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !conversations.putDraftCalled { t.Fatal("expected draft store to be called") } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["content"] != "未发送草稿" || data["version"].(float64) != 1 { t.Fatalf("unexpected draft put response: %#v", data) } req = httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+topic+"/draft", nil) req.Header.Set("Authorization", "Bearer token-1") rec = httptest.NewRecorder() serveIMTopicMessageAction(rec, req) if rec.Code != http.StatusOK { t.Fatalf("get status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["content"] != "未发送草稿" { t.Fatalf("unexpected draft get response: %#v", data) } } func TestServeIMFriendRequestCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1) mockStore.EXPECT().GetUidString().Return("request-1") oldRelations := store.Relations oldProfiles := store.Profiles relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}} store.Relations = relations store.Profiles = &testProfilesStore{} defer func() { store.Relations = oldRelations store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests", bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "fr-1") rec := httptest.NewRecorder() serveIMFriendRequests(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != types.FriendRequestPending || relations.created == nil || relations.created.IdempotencyKey != "fr-1" { t.Fatalf("unexpected friend request response: %#v created=%#v", data, relations.created) } } func TestServeIMFriendRequestCreateIgnoresRelationEventFailure(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1) mockStore.EXPECT().GetUidString().Return("request-1") oldRelations := store.Relations oldProfiles := store.Profiles oldSecurity := store.Security relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}} store.Relations = relations store.Profiles = &testProfilesStore{} store.Security = &testUserSecurityStore{err: errors.New("notification unavailable")} defer func() { store.Relations = oldRelations store.Profiles = oldProfiles store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests", bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`)) req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "fr-1") req.Header.Set("X-Request-ID", "req-event-fail") rec := httptest.NewRecorder() serveIMFriendRequests(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if relations.created == nil || relations.created.RequestID == "" { t.Fatalf("friend request was not created: %#v", relations.created) } } func TestPublishIMConversationSystemNoticeDeliversToOnlineP2PSessions(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) mockStore.EXPECT().IsOpen().Return(true).AnyTimes() oldStore := store.Store oldTopics := store.Topics oldMessages := store.Messages oldHub := globals.hub oldSessions := globals.sessionStore store.Store = mockStore topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Topics = topics store.Messages = messages globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 10)} globals.sessionStore = NewSessionStore(time.Minute) t.Cleanup(func() { store.Store = oldStore store.Topics = oldTopics store.Messages = oldMessages globals.hub = oldHub globals.sessionStore = oldSessions }) applicant := types.Uid(1) target := types.Uid(2) topic := applicant.P2PName(target) topics.EXPECT().Get(types.TenantID(7), topic).Return(&types.Topic{TenantID: 7, SeqId: 0}, nil) messages.EXPECT().Save(types.TenantID(7), gomock.Any(), nil, false).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != topic || msg.SeqId != 1 { t.Fatalf("unexpected saved message: %#v", msg) } return &types.MessageSaveResult{SeqId: msg.SeqId}, nil }) for _, uid := range []types.Uid{applicant, target} { session := &Session{ sid: uid.UserId() + "-session", proto: WEBSOCK, uid: uid, tenantID: types.TenantID(7), send: make(chan any, 2), subs: make(map[string]*Subscription), } globals.sessionStore.sessCache[session.sid] = session } publishIMConversationSystemNotice(types.TenantID(7), topic, "system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。", []imSystemMessageActor{ {UserID: applicant.UserId(), Click: map[string]any{"type": "user_profile", "target_id": applicant.UserId()}}, {UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}}, }, nil, map[string]any{"request_id": "frq_1"}) if len(globals.hub.routeSrv) != 3 { t.Fatalf("expected routed topic data and two presence updates, got %d", len(globals.hub.routeSrv)) } for _, uid := range []types.Uid{applicant, target} { session := globals.sessionStore.Get(uid.UserId() + "-session") select { case raw := <-session.send: msg, ok := raw.(*ServerComMessage) if !ok || msg.Data == nil { t.Fatalf("unexpected outbound for %s: %#v", uid.UserId(), raw) } if msg.Data.Topic != topic || msg.Data.SeqId != 1 { t.Fatalf("unexpected p2p data for %s: %#v", uid.UserId(), msg.Data) } if msg.Data.Head["x-im-template"] != "friend_added" || msg.Data.Head["x-im-type"] != "system_notice" { t.Fatalf("unexpected system notice head: %#v", msg.Data.Head) } default: t.Fatalf("online user %s did not receive system notice", uid.UserId()) } } } func TestPublishIMConversationSystemNoticePresenceUsesP2PTopic(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store oldTopics := store.Topics oldMessages := store.Messages oldHub := globals.hub mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Store = mockStore store.Topics = topics store.Messages = messages globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 4)} defer func() { store.Store = oldStore store.Topics = oldTopics store.Messages = oldMessages globals.hub = oldHub }() mockStore.EXPECT().IsOpen().Return(true) applicant := types.Uid(1) target := types.Uid(2) topic := applicant.P2PName(target) topics.EXPECT().Get(types.TenantID(7), topic).Return(&types.Topic{TenantID: 7, SeqId: 16}, nil) messages.EXPECT().Save(types.TenantID(7), gomock.Any(), nil, false).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != topic || msg.SeqId != 17 { t.Fatalf("unexpected saved message: %#v", msg) } return &types.MessageSaveResult{SeqId: msg.SeqId}, nil }) publishIMConversationSystemNotice(types.TenantID(7), topic, "system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。", []imSystemMessageActor{ {UserID: applicant.UserId(), Click: map[string]any{"type": "user_profile", "target_id": applicant.UserId()}}, {UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}}, }, nil, map[string]any{"request_id": "frq_1"}) select { case routed := <-globals.hub.routeSrv: if routed.Data == nil || routed.Data.Topic != topic || !routed.PreserveTopicName { t.Fatalf("unexpected system data broadcast: %#v", routed) } default: t.Fatal("expected system data broadcast") } for _, uid := range []types.Uid{applicant, target} { select { case routed := <-globals.hub.routeSrv: if routed.RcptTo != uid.UserId() || routed.Pres == nil || routed.Pres.Topic != "me" || routed.Pres.Src != topic || routed.Pres.SeqId != 17 || routed.Pres.SkipTopic != topic || !routed.PreserveTopicName { t.Fatalf("unexpected system presence for %s: %#v", uid.UserId(), routed) } default: t.Fatalf("expected system presence for %s", uid.UserId()) } } } func mustP2PNameForUser(t *testing.T, uid types.Uid, topic string) string { t.Helper() name, err := types.P2PNameForUser(uid, topic) if err != nil { t.Fatal(err) } return name } func TestServeIMFriendRequestCreateHonorsGroupPrivateChatSetting(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations oldProfiles := store.Profiles oldSubs := store.Subs oldGroups := store.GroupBusiness store.Relations = &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}} store.Profiles = &testProfilesStore{} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Subs = subs store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{ Topic: "grpAlpha", MemberPrivateChat: false, GroupStatus: types.GroupStatusActive, }} defer func() { store.Relations = oldRelations store.Profiles = oldProfiles store.Subs = oldSubs store.GroupBusiness = oldGroups }() memberMode := types.ModeCPublic for _, uid := range []types.Uid{1, 2} { subs.EXPECT().Get(types.TenantID(7), "grpAlpha", uid, false).Return(&types.Subscription{ TenantID: 7, Topic: "grpAlpha", User: uid.String(), ModeWant: memberMode, ModeGiven: memberMode, }, nil) } req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests", bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","source":"group","source_topic":"grpAlpha"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "fr-group-1") rec := httptest.NewRecorder() serveIMFriendRequests(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) } } func TestServeIMFriendRequestList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations now := time.Now().UTC().Round(time.Millisecond) relations := &testRelationsStore{friendRequests: []types.FriendRequestListItem{{ Request: types.FriendRequest{ TenantID: 7, RequestID: "frq_1", ApplicantID: 2, TargetID: 1, Message: "hi", Source: "search", Status: types.FriendRequestPending, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(time.Hour), }, Peer: &types.User{ObjHeader: types.ObjHeader{Id: types.Uid(2).String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice"}}, Direction: "inbound", }}} store.Relations = relations defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/friend-requests?direction=inbound&status=pending", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriendRequests(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["direction"] != "inbound" { t.Fatalf("unexpected friend request list: %#v", data) } } func TestServeIMFriendsList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations now := time.Now().UTC().Round(time.Millisecond) peer := types.Uid(2) relations := &testRelationsStore{friends: []types.FriendListItem{{ Friendship: types.Friendship{ TenantID: 7, UserID: 1, PeerID: 1, Status: types.RelationshipFriend, Topic: types.Uid(1).P2PName(types.Uid(1)), }, Peer: &types.User{ObjHeader: types.ObjHeader{Id: types.Uid(1).String()}, TenantID: 7, Public: map[string]any{"display_name": "Self"}}, }, { Friendship: types.Friendship{ TenantID: 7, UserID: 1, PeerID: peer, Status: types.RelationshipFriend, Remark: "小明", Topic: types.Uid(1).P2PName(peer), CreatedAt: now, UpdatedAt: now, }, Peer: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Ming"}}, }}} store.Relations = relations defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/friends?query=ming", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriends(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["display_name"] != "小明" || items[0].(map[string]any)["display_name_source"] != "remark" { t.Fatalf("unexpected friends list: %#v", data) } } func TestServeIMFriendsListUsesUserIDSourceOnlyForFallback(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations now := time.Now().UTC().Round(time.Millisecond) namedPeer := types.Uid(2) unnamedPeer := types.Uid(3) store.Relations = &testRelationsStore{friends: []types.FriendListItem{{ Friendship: types.Friendship{ TenantID: 7, UserID: 1, PeerID: namedPeer, Status: types.RelationshipFriend, Topic: types.Uid(1).P2PName(namedPeer), CreatedAt: now, UpdatedAt: now, }, Peer: &types.User{ObjHeader: types.ObjHeader{Id: namedPeer.String()}, TenantID: 7, Public: map[string]any{"fn": "yang"}}, }, { Friendship: types.Friendship{ TenantID: 7, UserID: 1, PeerID: unnamedPeer, Status: types.RelationshipFriend, Topic: types.Uid(1).P2PName(unnamedPeer), CreatedAt: now, UpdatedAt: now, }, Peer: &types.User{ObjHeader: types.ObjHeader{Id: unnamedPeer.String()}, TenantID: 7, Public: map[string]any{}}, }}} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/friends", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriends(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 2 { t.Fatalf("unexpected friends list length: %#v", data) } named := items[0].(map[string]any) if named["display_name"] != "yang" || named["display_name_source"] != "profile" { t.Fatalf("named friend should use profile display: %#v", named) } unnamed := items[1].(map[string]any) if unnamed["display_name"] != unnamed["user_id"] || unnamed["display_name_source"] != "user_id" { t.Fatalf("unnamed friend should use user_id fallback source: %#v", unnamed) } } func TestServeIMFriendRemark(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) store.Relations = &testRelationsStore{} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodPatch, "/v1/im/friends/"+peer.UserId(), bytes.NewBufferString(`{"remark":"小明"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriendResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipFriend || data["topic"] == "" { t.Fatalf("unexpected friend remark response: %#v", data) } if data["display_name"] != "小明" || data["remark"] != "小明" { t.Fatalf("friend remark should drive display name: %#v", data) } peerData := data["peer"].(map[string]any) if peerData["display_name"] != "小明" { t.Fatalf("peer display name should use remark: %#v", data) } } func TestServeIMFriendDeleteReturnsCannotSendContext(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) relations := &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: types.Uid(1).P2PName(peer), Relationship: types.RelationshipNone, CanSend: false, }} store.Relations = relations defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodDelete, "/v1/im/friends/"+peer.UserId(), nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriendResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !relations.deleted || relations.deleteTenant != 7 || relations.deleteUser != 1 || relations.deletePeer != peer { t.Fatalf("friend delete scope mismatch: %#v", relations) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipNone || data["can_send"] != false { t.Fatalf("friend delete did not return blocked send context: %#v", data) } } func TestServeIMUserSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) store.Relations = &testRelationsStore{searchResults: []types.UserSearchItem{{ User: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "avatar_file_id": "file-1", "public_account": "alice001"}}, Relationship: types.RelationshipNone, CanAdd: true, Action: "add", }}} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/users/search?q=alice&type=account", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMUserSearch(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["relationship"] != types.RelationshipNone || items[0].(map[string]any)["can_add"] != true || items[0].(map[string]any)["public_account"] != "alice001" { t.Fatalf("unexpected search response: %#v", data) } } func TestServeIMUserProfile(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldUsers := store.Users oldRelations := store.Relations oldProfiles := store.Profiles users := mock_store.NewMockUsersPersistenceInterface(ctrl) peer := types.Uid(2) users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "nickname": "Alice", "avatar_file_id": "file-1", "signature": "hello"}, }, nil) store.Users = users store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: types.Uid(1).P2PName(peer), Relationship: types.RelationshipFriend, CanSend: true, Remark: "老同事", }} store.Profiles = &testProfilesStore{} defer func() { store.Users = oldUsers store.Relations = oldRelations store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMUserResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipFriend || data["signature"] != "hello" || data["topic"] == "" { t.Fatalf("unexpected profile response: %#v", data) } if data["display_name"] != "老同事" || data["remark"] != "老同事" { t.Fatalf("profile display name should use friend remark: %#v", data) } if data["nickname"] != "Alice" { t.Fatalf("profile should include original nickname: %#v", data) } } func TestServeIMUserQRCodes(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldUsers := store.Users oldRelations := store.Relations oldProfiles := store.Profiles users := mock_store.NewMockUsersPersistenceInterface(ctrl) peer := types.Uid(2) users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice"}, }, nil) store.Users = users store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: types.Uid(1).P2PName(peer), Relationship: types.RelationshipFriend, CanSend: true, }} store.Profiles = &testProfilesStore{} defer func() { store.Users = oldUsers store.Relations = oldRelations store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPost, "/v1/im/users/"+peer.UserId()+"/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMUserResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["qr_token"] == "" || !strings.Contains(data["qr_payload"].(string), "purpose=add_friend") { t.Fatalf("unexpected qr response: %#v", data) } } func TestServeIMUserQRCodesByUsername(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldUsers := store.Users oldRelations := store.Relations oldProfiles := store.Profiles users := mock_store.NewMockUsersPersistenceInterface(ctrl) peer := types.Uid(2) peerUser := &types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "public_account": "alice001"}, } users.EXPECT().Get(types.TenantID(7), peer).Return(peerUser, nil) store.Users = users store.Relations = &testRelationsStore{ context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: types.Uid(1).P2PName(peer), Relationship: types.RelationshipFriend, CanSend: true, }, searchResults: []types.UserSearchItem{{User: peerUser, Relationship: types.RelationshipFriend}}, } store.Profiles = &testProfilesStore{} defer func() { store.Users = oldUsers store.Relations = oldRelations store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPost, "/v1/im/users/alice001/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMUserResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) token := data["qr_token"].(string) resolved, err := parseIMQRCodeToken(token, "add_friend", time.Now().UTC()) if err != nil { t.Fatalf("parse token: %v", err) } if resolved.UserID != peer.UserId() { t.Fatalf("token user id: got %s, want %s", resolved.UserID, peer.UserId()) } } func TestServeIMUserQRCodesRespectsPrivacy(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldUsers := store.Users oldRelations := store.Relations oldProfiles := store.Profiles users := mock_store.NewMockUsersPersistenceInterface(ctrl) peer := types.Uid(2) users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, }, nil) store.Users = users store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: types.Uid(1).P2PName(peer), Relationship: types.RelationshipFriend, CanSend: true, }} store.Profiles = &testProfilesStore{privacy: &types.UserPrivacySettings{ UserID: peer, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": false, "group": true}, ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, }} defer func() { store.Users = oldUsers store.Relations = oldRelations store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPost, "/v1/im/users/"+peer.UserId()+"/qrcodes", bytes.NewBufferString(`{}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMUserResource(rec, req) if rec.Code != http.StatusForbidden || decodeIMHTTPResponse(t, rec).Reason != imReasonPermissionDenied { t.Fatalf("privacy status: got %d body=%s", rec.Code, rec.Body.String()) } } func TestServeIMBlocks(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations now := time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC) peer := types.Uid(2) store.Relations = &testRelationsStore{blocks: []types.BlockListItem{{ Block: types.UserBlock{ TenantID: 7, BlockerID: 1, BlockedID: peer, CreatedAt: now, UpdatedAt: now, }, User: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Blocked User"}}, }}} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/blocks", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMBlocks(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["user_id"] != peer.UserId() { t.Fatalf("unexpected blocks response: %#v", data) } } func TestServeIMBlocksBatchDelete(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations store.Relations = &testRelationsStore{} defer func() { store.Relations = oldRelations }() peer := types.Uid(2) req := httptest.NewRequest(http.MethodPost, "/v1/im/blocks/batch-delete", bytes.NewBufferString(`{"user_ids":["`+peer.UserId()+`"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMBlockUser(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["ok"] != true { t.Fatalf("unexpected batch delete response: %#v", data) } } func ptrTime(t time.Time) *time.Time { return &t } func ptrString(s string) *string { return &s }