feat: add meeting notes backend

This commit is contained in:
richjohnrambo
2026-09-05 17:55:27 +08:00
parent 9e290b76b3
commit baf15848d3
12 changed files with 1633 additions and 52 deletions
+436 -7
View File
@@ -269,6 +269,14 @@ type testMeetingsStore struct {
responseVersion int
}
type testMeetingNotesStore struct {
meetings []types.Meeting
files []types.MeetingNoteFile
query string
cursor string
limit int
}
type testDailyReportsStore struct {
report *types.DailyReport
saved bool
@@ -658,6 +666,43 @@ func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, r
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)
return &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,
}, 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
@@ -1772,6 +1817,51 @@ func TestServeIMPublicUploadsAllowsGroupAvatar(t *testing.T) {
}
}
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
@@ -2733,6 +2823,61 @@ func TestServeIMMeetingsList(t *testing.T) {
}
}
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()
@@ -2772,6 +2917,100 @@ func TestServeIMMeetingMyResponse(t *testing.T) {
}
}
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)
if data["recorder_user_id"] != viewer.UserId() || 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")
oldMeetingNotes := store.MeetingNotes
store.MeetingNotes = &testMeetingNotesStore{}
defer func() {
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)
}
}
func TestServeIMDailyReportsCreateAndList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -3697,9 +3936,23 @@ func TestServeIMGroupList(t *testing.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
defer func() { store.Users = oldUsers }()
store.GroupBusiness = groupBusiness
defer func() {
store.Users = oldUsers
store.GroupBusiness = oldGroupBusiness
}()
sub := types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
@@ -3708,8 +3961,8 @@ func TestServeIMGroupList(t *testing.T) {
ModeGiven: types.ModeCFull,
ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)},
}
sub.SetPublic(map[string]any{"name": "Alpha", "avatar_file_id": "file-1"})
sub.SetSubCnt(3)
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)
@@ -3723,7 +3976,7 @@ func TestServeIMGroupList(t *testing.T) {
}
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)["my_role"] != "owner" {
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)
}
}
@@ -4086,7 +4339,8 @@ func TestServeIMGroupLeave(t *testing.T) {
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("test")
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
@@ -4102,6 +4356,7 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
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"}`))
@@ -4115,10 +4370,10 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["status"] != "joined" {
if len(items) != 1 || items[0].(map[string]any)["status"] != types.GroupInvitationStatusPending {
t.Fatalf("unexpected invitation response: %#v", data)
}
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target {
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target || groupStore.invitations[0].InvitationID != "ginv_test" {
t.Fatalf("invitation was not persisted: %#v", groupStore.invitations)
}
}
@@ -4461,6 +4716,8 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
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,
@@ -4474,11 +4731,36 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
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()
@@ -4516,6 +4798,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
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{{
@@ -4529,13 +4812,23 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
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",
@@ -4561,6 +4854,104 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
}
}
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)
}
}
func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -5149,6 +5540,14 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) {
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")
@@ -5306,6 +5705,36 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
}
}
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()