fix im workbench and profile regressions

This commit is contained in:
richjohnrambo
2026-09-11 17:25:24 +08:00
parent 1c2132eebc
commit 367bed2940
6 changed files with 702 additions and 71 deletions
+426 -19
View File
@@ -307,6 +307,11 @@ type testMailboxStore struct {
config *types.MailboxConfig
submission *types.MailboxSubmission
idempotencyKey string
submitErr error
}
type testUserSecurityStore struct {
unreadByPrefix map[string]int
}
type testProfilesStore struct {
@@ -342,6 +347,7 @@ type testGroupBusinessStore struct {
member *types.GroupMember
joinRequests []types.GroupJoinRequest
announcement *types.GroupAnnouncement
announcements map[string]*types.GroupAnnouncement
mutes []types.GroupMute
files []types.GroupFile
invitations []types.GroupInvitation
@@ -472,6 +478,18 @@ func (s *testGroupBusinessStore) LatestAnnouncement(_ types.TenantID, topic stri
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() {
@@ -906,6 +924,9 @@ func (s *testMailboxStore) Config(_ types.TenantID, _ types.Uid) (*types.Mailbox
}
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
@@ -917,6 +938,81 @@ func (s *testMailboxStore) Submit(_ types.TenantID, uid types.Uid, submission *t
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, _ *types.SystemNotification) error {
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)
@@ -2577,14 +2673,18 @@ func TestServeIMWorkbench(t *testing.T) {
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)
@@ -2601,13 +2701,17 @@ func TestServeIMWorkbench(t *testing.T) {
t.Fatalf("unexpected layout version: %#v", data)
}
items := data["modules"].([]any)
if len(items) != 1 {
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"])
@@ -2811,10 +2915,31 @@ 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
oldInfrastructure := store.HTTPInfrastructure
oldMeetings := store.Meetings
meetings := &testMeetingsStore{}
security := &testSecurityStore{}
infrastructure := &testHTTPInfrastructureStore{}
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.HTTPInfrastructure = infrastructure
store.Meetings = meetings
defer func() { store.Meetings = oldMeetings }()
defer func() {
kafkaBus = oldKafkaBus
publishKafkaAuditEventEnvelope = oldPublisher
store.Security = oldSecurity
store.HTTPInfrastructure = oldInfrastructure
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))
@@ -2833,6 +2958,12 @@ func TestServeIMMeetingsCreate(t *testing.T) {
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 || len(infrastructure.events) != 0 {
t.Fatalf("meeting create HTTP layer must not duplicate workbench notifications/events: notifications=%#v events=%#v", security.notifications, infrastructure.events)
}
}
func TestServeIMMeetingsList(t *testing.T) {
@@ -3485,6 +3616,53 @@ func TestServeIMMailboxSubmission(t *testing.T) {
}
}
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()
@@ -4580,21 +4758,33 @@ func TestServeIMGroupFiles(t *testing.T) {
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","file_type":"document","file_size":123}`, fileID)))
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()
@@ -4632,6 +4822,75 @@ func TestServeIMGroupFiles(t *testing.T) {
}
}
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()
@@ -4823,6 +5082,70 @@ func TestServeIMGroupAnnouncementLatestIncludesEditPermission(t *testing.T) {
}
}
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()
@@ -5319,6 +5642,7 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
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
@@ -5331,7 +5655,7 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ItemType: "text",
SourceTopic: "grpAlpha",
SourceSeq: 3,
Snapshot: map[string]any{"text": "hello"},
Snapshot: map[string]any{"head": map[string]any{"type": "text"}, "content": map[string]any{"text": "hello"}, "text": "hello"},
Availability: "available",
CollectedAt: now,
}}}
@@ -5352,20 +5676,29 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, 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["favorite_id"] != "fav_1" || content["comment"] != "FYI" {
t.Fatalf("unexpected forward content: %#v", content)
}
return &types.MessageSaveResult{SeqId: 11, MarkedReadBySender: true}, 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")
@@ -5375,7 +5708,7 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
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 {
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)
}
}
@@ -5502,6 +5835,80 @@ func TestServeIMMessageForwardSeparateSavesMessages(t *testing.T) {
}
}
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()