diff --git a/server/im_http.go b/server/im_http.go index 85f8adf..b8894a6 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -407,8 +407,8 @@ type imMessageRevisionRequest struct { type imFavoritesBatchCreateRequest struct { Messages []struct { - Topic string `json:"topic"` - Seq int64 `json:"seq,string"` + Topic string `json:"topic"` + Seq imForwardSeq `json:"seq"` } `json:"messages"` } @@ -3557,27 +3557,28 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) { failed := make([]map[string]any, 0) for _, src := range body.Messages { sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic) - if !sourceOK || src.Seq <= 0 { - failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest}) + seq := int64(src.Seq) + if !sourceOK || seq <= 0 { + failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": imReasonMalformedRequest}) continue } if err := requireTopicReader(principal, sourceTopic); err != nil { - failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)}) + failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)}) continue } - msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, src.Seq) + msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, seq) if err != nil { - failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)}) + failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)}) continue } - idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, src.Seq) + idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, seq) fav := &types.FavoriteItem{ TenantID: principal.TenantID, FavoriteID: "fav_" + store.Store.GetUidString(), UserID: principal.Uid, ItemType: imMessageType(msg), SourceTopic: sourceTopic, - SourceSeq: src.Seq, + SourceSeq: seq, Snapshot: favoriteSnapshot(msg), AttachmentRefs: favoriteAttachmentRefs(msg), Availability: messageAvailability(*msg), @@ -3588,7 +3589,7 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) { } item, err := store.Favorites.Create(principal.TenantID, fav) if err != nil { - failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)}) + failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": relationErrorReason(err)}) continue } if item.FavoriteID == fav.FavoriteID { diff --git a/server/im_http_test.go b/server/im_http_test.go index 14c6955..8dde05f 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -4929,6 +4929,63 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) { } } +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)