Fix IM report and group permission regressions

This commit is contained in:
richjohnrambo
2026-09-02 10:01:38 +08:00
parent 15a4174ed1
commit ac45223e39
5 changed files with 299 additions and 39 deletions
@@ -0,0 +1,56 @@
//go:build mysql
package mysql
import (
"os"
"strings"
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestCanUseManagedReportsRequiresAllOrganizationVisibility(t *testing.T) {
if !canUseManagedReports(organizationVisibility{Scope: orgVisibilityAll}) {
t.Fatal("all visibility should be allowed to use managed report scope")
}
for _, scope := range []string{orgVisibilitySelf, orgVisibilityFriends, ""} {
if canUseManagedReports(organizationVisibility{Scope: scope}) {
t.Fatalf("scope %q should not be allowed to use managed report scope", scope)
}
}
}
func TestReportMemberScopeWhereIncludesDepartmentAndVisibility(t *testing.T) {
where, args := reportMemberScopeWhere(organizationVisibility{Scope: orgVisibilityFriends, UserID: 42}, "dep_ops")
joined := strings.Join(where, " AND ")
if !strings.Contains(joined, "m.department_id=?") {
t.Fatalf("department filter missing from where: %s", joined)
}
if !strings.Contains(joined, "im_friendships") {
t.Fatalf("friend visibility filter missing from where: %s", joined)
}
if len(args) != 4 || args[0] != "dep_ops" || args[1] != int64(42) || args[2] != int64(42) || args[3] != types.RelationshipFriend {
t.Fatalf("unexpected args: %#v", args)
}
}
func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) {
mode := groupAdminAccessMode()
if !mode.IsAdmin() {
t.Fatalf("admin mode should grant admin access: %s", mode.String())
}
if mode.IsOwner() {
t.Fatalf("admin mode must not grant owner access: %s", mode.String())
}
}
func TestMessageSearchDoesNotClearScannedHead(t *testing.T) {
source, err := os.ReadFile("tenant_adapter.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(source), "msg.Head = kvMapFromJSON(msg.Head)") {
t.Fatal("message search must not clear Head after sqlx StructScan has already decoded it")
}
}
+156 -26
View File
@@ -2337,7 +2337,6 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
return nil, "", err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue
@@ -2398,7 +2397,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
return nil, "", err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue
@@ -4593,12 +4591,24 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope != "managed" {
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if from != "" {
@@ -4622,10 +4632,6 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_daily_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
@@ -4769,14 +4775,14 @@ func (a *adapter) TenantDailyReportSubmit(id t.TenantID, uid t.Uid, reportID str
return a.TenantDailyReportGet(id, uid, reportID)
}
func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, _ string) (*t.DailyReportStats, error) {
func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, departmentID string) (*t.DailyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportStats(id, uid, date, "")
return tenantAdp.TenantDailyReportStats(id, uid, date, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
@@ -4786,11 +4792,22 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim
if cancel != nil {
defer cancel()
}
stats := &t.DailyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports WHERE report_date=? AND status=?`, date.Format("2006-01-02"), t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil {
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.DailyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_date=?", "r.status=?"}
submittedArgs := []any{date.Format("2006-01-02"), t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
@@ -4800,20 +4817,114 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim
return stats, nil
}
func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, _ time.Time, userIDs []t.Uid, _ string) ([]t.Uid, error) {
func reportMemberScopeWhere(visibility organizationVisibility, departmentID string) ([]string, []any) {
memberWhere := []string{"m.employment_state='active'"}
memberArgs := []any{}
if strings.TrimSpace(departmentID) != "" {
memberWhere = append(memberWhere, "m.department_id=?")
memberArgs = append(memberArgs, strings.TrimSpace(departmentID))
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
memberWhere = append(memberWhere, visibilitySQL)
memberArgs = append(memberArgs, visibilityArgs...)
}
return memberWhere, memberArgs
}
func canUseManagedReports(visibility organizationVisibility) bool {
return visibility.Scope == orgVisibilityAll
}
func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, date time.Time, userIDs []t.Uid, idempotencyKey string) ([]t.Uid, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportRemind(id, uid, time.Time{}, userIDs, "")
return tenantAdp.TenantDailyReportRemind(id, uid, date, userIDs, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() || len(userIDs) == 0 || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
return userIDs, nil
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if !canUseManagedReports(visibility) {
return nil, t.ErrPermissionDenied
}
targetIDs := make([]int64, 0, len(userIDs))
seen := map[int64]struct{}{}
for _, target := range userIDs {
if target.IsZero() {
return nil, t.ErrMalformed
}
decoded := decodeUID(target)
if decoded <= 0 {
return nil, t.ErrMalformed
}
if _, ok := seen[decoded]; ok {
continue
}
seen[decoded] = struct{}{}
targetIDs = append(targetIDs, decoded)
}
query, args, err := sqlx.In(`SELECT DISTINCT m.user_id FROM im_org_members AS m
WHERE m.employment_state='active' AND m.user_id IN (?) AND NOT EXISTS (
SELECT 1 FROM im_daily_reports AS r
WHERE r.author_id=m.user_id AND r.report_date=? AND r.status=?
)`, targetIDs, date.Format("2006-01-02"), t.DailyReportStatusSubmitted)
if err != nil {
return nil, err
}
query = a.db.Rebind(query)
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
reminded := make([]t.Uid, 0, len(targetIDs))
for rows.Next() {
var raw int64
if err = rows.Scan(&raw); err != nil {
return nil, err
}
target := store.EncodeUid(raw)
reminded = append(reminded, target)
}
if err = rows.Err(); err != nil {
return nil, err
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for _, target := range reminded {
eventID := t.StableEventID("daily_report_reminder", date.Format("2006-01-02"), target.UserId(), strings.TrimSpace(idempotencyKey))
if err = insertSystemNotificationTx(tx, target, eventID, "daily_report.reminded", "日报提醒", date.Format("2006-01-02"), map[string]any{
"date": date.Format("2006-01-02"),
}); err != nil {
return nil, err
}
}
if err = tx.Commit(); err != nil {
return nil, err
}
committed = true
return reminded, nil
}
func scanDailyReport(rows *sqlx.Rows) (*t.DailyReport, error) {
@@ -4869,12 +4980,24 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope != "managed" {
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if fromMonth != "" {
@@ -4898,10 +5021,6 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_monthly_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
@@ -5076,14 +5195,14 @@ func (a *adapter) TenantMonthlyReportReopen(id t.TenantID, uid t.Uid, reportID s
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ string) (*t.MonthlyReportStats, error) {
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, departmentID string) (*t.MonthlyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportStats(id, uid, month, "")
return tenantAdp.TenantMonthlyReportStats(id, uid, month, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
@@ -5093,11 +5212,22 @@ func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ st
if cancel != nil {
defer cancel()
}
stats := &t.MonthlyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports WHERE report_month=? AND status=?`, month, t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil {
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.MonthlyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_month=?", "r.status=?"}
submittedArgs := []any{month, t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
+9 -5
View File
@@ -1170,8 +1170,8 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), now, topic, decodeUID(target))
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), modeGiven.String(), now, topic, decodeUID(target))
if err != nil {
return err
}
@@ -1194,6 +1194,10 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return tx.Commit()
}
func groupAdminAccessMode() t.AccessMode {
return t.ModeCPublic | t.ModeApprove
}
func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid,
version int, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
@@ -1214,10 +1218,10 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
uid t.Uid
mode t.AccessMode
role string
}{{previousOwner, t.ModeCAdmin | t.ModeCPublic, t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
}{{previousOwner, groupAdminAccessMode(), t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
for _, update := range updates {
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), now, topic, decodeUID(update.uid))
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), update.mode.String(), now, topic, decodeUID(update.uid))
if execErr != nil {
return execErr
}
+5 -3
View File
@@ -2381,8 +2381,8 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
"permissions": map[string]any{
"can_create": true,
"makeup_allowed_days": 7,
"can_view_managed": true,
"can_remind_unsubmitted": true,
"can_view_managed": false,
"can_remind_unsubmitted": false,
},
})
case http.MethodPost:
@@ -3882,6 +3882,7 @@ func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRecallRequest
if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 {
if body.Version <= 0 {
@@ -3933,6 +3934,7 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRevisionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
@@ -5294,7 +5296,7 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail
}
item["user_id"] = uid.UserId()
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
types.GroupMemberRoleAdmin, types.ModeCAdmin|types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil {
types.GroupMemberRoleAdmin, types.ModeCPublic|types.ModeApprove, time.Now().UTC().Round(time.Millisecond)); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
+73 -5
View File
@@ -333,6 +333,7 @@ type testGroupBusinessStore struct {
invitations []types.GroupInvitation
updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch
updatedRoleMode types.AccessMode
}
func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any,
@@ -517,8 +518,9 @@ func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid,
}
func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid,
role string, _ types.AccessMode, _ time.Time) error {
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
}
@@ -3692,7 +3694,8 @@ func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) {
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{}
groupStore := &testGroupBusinessStore{}
store.GroupBusiness = groupStore
defer func() {
store.Store = oldStore
store.Topics = oldTopics
@@ -3771,7 +3774,8 @@ func TestServeIMGroupLeave(t *testing.T) {
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{}
groupStore := &testGroupBusinessStore{}
store.GroupBusiness = groupStore
defer func() {
store.Topics = oldTopics
store.Subs = oldSubs
@@ -4020,7 +4024,8 @@ func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{}
groupStore := &testGroupBusinessStore{}
store.GroupBusiness = groupStore
defer func() {
store.Store = oldStore
store.Topics = oldTopics
@@ -4083,7 +4088,8 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{}
groupStore := &testGroupBusinessStore{}
store.GroupBusiness = groupStore
defer func() {
store.Store = oldStore
store.Topics = oldTopics
@@ -4106,6 +4112,9 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
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", bytes.NewBufferString(`{"members":[{"user_id":"`+target.UserId()+`"}],"reason":"test"}`))
muteReq.Header.Set("Authorization", "Bearer token-1")
@@ -5165,6 +5174,65 @@ func TestServeIMMessageRecall(t *testing.T) {
}
}
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()