feat: add kafka admin integration
This commit is contained in:
+194
-36
@@ -40,6 +40,7 @@ type testVerificationStore struct {
|
||||
type testHTTPInfrastructureStore struct {
|
||||
records map[string]*types.HTTPIdempotencyRecord
|
||||
events []types.BusinessEvent
|
||||
outbox []types.KafkaOutboxRecord
|
||||
}
|
||||
|
||||
type testTenantEntryStore struct {
|
||||
@@ -147,6 +148,57 @@ func (s *testHTTPInfrastructureStore) MarkEventDelivered(_ types.TenantID, _ str
|
||||
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, _ *types.KafkaDeadLetter, _ time.Time) error {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) CreateKafkaOutbox(_ types.TenantID, record *types.KafkaOutboxRecord) error {
|
||||
s.outbox = append(s.outbox, *record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) ListPendingKafkaOutbox(_ types.TenantID, _ int, _ time.Time) ([]types.KafkaOutboxRecord, error) {
|
||||
return append([]types.KafkaOutboxRecord(nil), s.outbox...), nil
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) MarkKafkaOutboxSent(_ types.TenantID, _ string, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) MarkKafkaOutboxFailed(_ types.TenantID, _, _, _ string, _, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type testRelationsStore struct {
|
||||
context *types.RelationshipContext
|
||||
created *types.FriendRequest
|
||||
@@ -1261,6 +1313,23 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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-Tinode-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
|
||||
@@ -2348,6 +2417,42 @@ func TestServeIMDailyReportsCreateAndList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -2419,42 +2524,6 @@ func TestServeIMDailyReportStatisticsAndReminders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 TestServeIMMonthlyReportsCreate(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
@@ -3988,6 +4057,83 @@ func TestServeIMFavoriteForwardRejectsUnauthorizedTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 TestServeIMSecurityURLCheck(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
@@ -4533,6 +4679,18 @@ func TestServeIMConversationContextAcceptsUserTopic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user