feat: complete IM P0 and P1 reliability work

This commit is contained in:
richjohnrambo
2026-08-17 13:43:27 +08:00
parent ddf0f7a55e
commit f2c229135b
43 changed files with 4512 additions and 1181 deletions
+302 -19
View File
@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -33,6 +34,57 @@ type testVerificationStore struct {
tokenHash string
}
type testHTTPInfrastructureStore struct {
records map[string]*types.HTTPIdempotencyRecord
events []types.BusinessEvent
}
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
}
func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
if s.records == nil {
s.records = make(map[string]*types.HTTPIdempotencyRecord)
}
key := s.recordKey(record)
if existing := s.records[key]; existing != nil {
copy := *existing
copy.ResponseBody = append([]byte(nil), existing.ResponseBody...)
return &copy, false, nil
}
copy := *record
copy.State = types.HTTPIdempotencyProcessing
s.records[key] = &copy
return &copy, true, nil
}
func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
copy := *record
copy.State = types.HTTPIdempotencyCompleted
copy.ResponseBody = append([]byte(nil), record.ResponseBody...)
s.records[s.recordKey(record)] = &copy
return nil
}
func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
delete(s.records, s.recordKey(record))
return nil
}
func (s *testHTTPInfrastructureStore) CreateEvent(_ types.TenantID, event *types.BusinessEvent) error {
s.events = append(s.events, *event)
return nil
}
func (s *testHTTPInfrastructureStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.BusinessEvent, error) {
return append([]types.BusinessEvent(nil), s.events...), nil
}
func (s *testHTTPInfrastructureStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
type testRelationsStore struct {
context *types.RelationshipContext
created *types.FriendRequest
@@ -149,6 +201,12 @@ type testGroupBusinessStore struct {
updatedMembers []types.GroupMemberPatch
}
func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any,
settings *types.GroupSettings, _ []types.Uid, _ any, _ time.Time) error {
s.settings = settings
return nil
}
func (s *testGroupBusinessStore) CreateSettings(_ types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) {
s.settings = settings
return settings, nil
@@ -225,7 +283,7 @@ func (s *testGroupBusinessStore) ListJoinRequests(_ types.TenantID, _ string, st
return out, "", nil
}
func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, now time.Time) (*types.GroupJoinRequest, error) {
func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, _ any, now time.Time) (*types.GroupJoinRequest, error) {
status := types.GroupJoinRequestStatusRejected
if action == "accept" {
status = types.GroupJoinRequestStatusAccepted
@@ -267,6 +325,61 @@ func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types.
return nil
}
func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, _ any, now time.Time) (bool, error) {
for _, invitation := range s.invitations {
if invitation.Topic == topic && invitation.TargetUserID == target {
return false, nil
}
}
s.invitations = append(s.invitations, types.GroupInvitation{Topic: topic, InviterUserID: inviter,
TargetUserID: target, Message: message, IdempotencyKey: idempotencyKey,
Status: types.GroupInvitationStatusJoined, CreatedAt: now, UpdatedAt: now})
return true, nil
}
func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid, _ time.Time) error {
return nil
}
func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid,
role string, _ types.AccessMode, _ time.Time) error {
s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role})
s.member = &types.GroupMember{Topic: topic, UserID: target, Role: role, MemberStatus: types.GroupMemberStatusActive}
return nil
}
func (s *testGroupBusinessStore) TransferOwner(_ types.TenantID, topic string, _, target types.Uid, _ int, _ time.Time) error {
settings, _ := s.GetSettings(0, topic)
settings.OwnerUserID = target
s.settings = settings
return nil
}
func (s *testGroupBusinessStore) CanPost(_ types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
if admin {
return true, nil
}
if s.settings != nil && s.settings.Topic == topic && s.settings.MuteAll {
return false, nil
}
for _, mute := range s.mutes {
if mute.Topic == topic && mute.UserID == uid && mute.Status == types.GroupMuteStatusActive &&
(mute.ExpiresAt == nil || mute.ExpiresAt.After(now)) {
return false, nil
}
}
return true, nil
}
func (s *testGroupBusinessStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.GroupEvent, error) {
return nil, nil
}
func (s *testGroupBusinessStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
func defaultStringPtr(value *string, fallback string) string {
if value == nil {
return fallback
@@ -501,6 +614,12 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
if patch.AddMeMethods != nil {
privacy.AddMeMethods = patch.AddMeMethods
}
if patch.ProfileVisibility != nil {
privacy.ProfileVisibility = *patch.ProfileVisibility
}
if patch.LastSeenVisibility != nil {
privacy.LastSeenVisibility = *patch.LastSeenVisibility
}
if patch.ReadReceipts != nil {
privacy.ReadReceipts = *patch.ReadReceipts
}
@@ -821,6 +940,84 @@ func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantI
return mockStore
}
func TestIMHTTPProtocolHeaders(t *testing.T) {
apiKey := makeTestAPIKey(t)
called := 0
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
called++
writeIMSuccess(wrt, req, map[string]any{"ok": true})
}))
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || called != 0 {
t.Fatalf("missing API key: status=%d called=%d", rec.Code, called)
}
if rec.Header().Get("X-Request-ID") == "" {
t.Fatal("server did not generate X-Request-ID")
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported {
t.Fatalf("missing schema: status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "request-1")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" {
t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID"))
}
}
func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldInfrastructure := store.HTTPInfrastructure
infrastructure := &testHTTPInfrastructureStore{}
store.HTTPInfrastructure = infrastructure
defer func() { store.HTTPInfrastructure = oldInfrastructure }()
called := 0
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
called++
body, _ := io.ReadAll(req.Body)
writeIMSuccess(wrt, req, map[string]any{"body": string(body), "call": called})
}))
apiKey := makeTestAPIKey(t)
request := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("X-Tinode-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("Idempotency-Key", "same-key")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
first := request(`{"value":1}`)
second := request(`{"value":1}`)
if first.Code != http.StatusOK || second.Code != http.StatusOK || called != 1 {
t.Fatalf("replay failed: first=%d second=%d called=%d", first.Code, second.Code, called)
}
if second.Header().Get("X-Idempotent-Replay") != "true" || first.Body.String() != second.Body.String() {
t.Fatalf("response was not replayed: first=%s second=%s", first.Body.String(), second.Body.String())
}
conflict := request(`{"value":2}`)
if conflict.Code != http.StatusConflict || decodeIMHTTPResponse(t, conflict).Reason != imReasonIdempotencyConflict || called != 1 {
t.Fatalf("conflict failed: status=%d called=%d body=%s", conflict.Code, called, conflict.Body.String())
}
}
func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
@@ -1364,11 +1561,16 @@ func TestServeIMWorkbench(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldWorkbench := store.Workbench
oldTenants := store.Tenants
store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"},
}}
defer func() { store.Workbench = oldWorkbench }()
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
defer func() {
store.Workbench = oldWorkbench
store.Tenants = oldTenants
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil)
req.Header.Set("Authorization", "Bearer token-1")
@@ -1391,6 +1593,10 @@ func TestServeIMWorkbench(t *testing.T) {
if item["code"] != "organization" || item["route"] != "/organization" {
t.Fatalf("unexpected module: %#v", item)
}
enterprise := data["enterprise"].(map[string]any)
if enterprise["tenant_code"] != "acme" {
t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"])
}
}
func TestServeIMOrganizationDepartments(t *testing.T) {
@@ -1838,6 +2044,43 @@ func TestServeIMMePrivacy(t *testing.T) {
}
}
func TestIMCanViewLastSeen(t *testing.T) {
oldProfiles := store.Profiles
oldRelations := store.Relations
defer func() {
store.Profiles = oldProfiles
store.Relations = oldRelations
}()
viewer, target := types.Uid(1), types.Uid(2)
profiles := &testProfilesStore{privacy: &types.UserPrivacySettings{
UserID: target, LastSeenVisibility: "everyone",
}}
store.Profiles = profiles
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipNone,
}}
if !imCanViewLastSeen(7, viewer, target) {
t.Fatal("everyone visibility must expose last seen")
}
profiles.privacy.LastSeenVisibility = "nobody"
if imCanViewLastSeen(7, viewer, target) {
t.Fatal("nobody visibility must hide last seen")
}
profiles.privacy.LastSeenVisibility = "friends"
if imCanViewLastSeen(7, viewer, target) {
t.Fatal("non-friend must not see friends-only last seen")
}
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipFriend,
}}
if !imCanViewLastSeen(7, viewer, target) {
t.Fatal("friend must see friends-only last seen")
}
}
func TestServeIMAppReleaseLatest(t *testing.T) {
oldTenants := store.Tenants
oldReleases := store.AppReleases
@@ -2118,13 +2361,16 @@ func TestServeIMMessageReceipts(t *testing.T) {
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldTopics := store.Topics
oldProfiles := store.Profiles
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
store.Profiles = &testProfilesStore{}
defer func() {
store.Subs = oldSubs
store.Topics = oldTopics
store.Profiles = oldProfiles
}()
mySub := &types.Subscription{
@@ -2326,15 +2572,6 @@ func TestServeIMGroupCreate(t *testing.T) {
store.GroupBusiness = oldGroupBusiness
}()
topics.EXPECT().Create(types.TenantID(7), gomock.Any(), types.Uid(42), gomock.Any()).DoAndReturn(
func(_ types.TenantID, topic *types.Topic, _ types.Uid, _ any) error {
if topic.Id != "grpnewgroup" {
t.Fatalf("unexpected topic id: %s", topic.Id)
}
return nil
})
subs.EXPECT().Create(types.TenantID(7), gomock.Any()).Return(nil)
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpnewgroup").Return(nil)
topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{
ObjHeader: types.ObjHeader{Id: "grpnewgroup"},
TenantID: 7,
@@ -2475,9 +2712,6 @@ func TestServeIMGroupLeave(t *testing.T) {
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
subs.EXPECT().Delete(types.TenantID(7), "grpAlpha", types.Uid(42)).Return(nil)
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpAlpha").Return(nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/leave", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -2493,6 +2727,46 @@ func TestServeIMGroupLeave(t *testing.T) {
}
}
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
groupStore := &testGroupBusinessStore{}
store.Subs = subs
store.GroupBusiness = groupStore
defer func() {
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
owner := types.Uid(42)
target := types.Uid(43)
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)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "invite-1")
rec := httptest.NewRecorder()
serveIMGroupResource(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)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["status"] != "joined" {
t.Fatalf("unexpected invitation response: %#v", data)
}
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target {
t.Fatalf("invitation was not persisted: %#v", groupStore.invitations)
}
}
func TestServeIMGroupQRCodesAndResolve(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -2595,7 +2869,8 @@ func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
store.GroupBusiness = oldGroupBusiness
}()
selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(2)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(nil, nil)
annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`))
annReq.Header.Set("Authorization", "Bearer token-1")
@@ -2663,10 +2938,7 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
members := []types.Subscription{*ownerSub, member}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3)
topics.EXPECT().GetUsers(types.TenantID(7), "grpAlpha", gomock.Any()).Return(members, nil).Times(2)
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", target, gomock.Any()).Return(nil).Times(2)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil)
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", owner, gomock.Any()).Return(nil)
topics.EXPECT().OwnerChange(types.TenantID(7), "grpAlpha", target).Return(nil)
adminReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/admins/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"version":1}`))
adminReq.Header.Set("Authorization", "Bearer token-1")
@@ -3236,6 +3508,7 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) {
store.Store = mockStore
oldUsers := store.Users
oldRelations := store.Relations
oldProfiles := store.Profiles
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Users = users
owner := types.Uid(1)
@@ -3258,10 +3531,12 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) {
Relationship: types.RelationshipNone,
CanSend: false,
}}
store.Profiles = &testProfilesStore{}
defer func() {
store.Store = oldStore
store.Users = oldUsers
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/me/qrcodes", bytes.NewBufferString(`{"purpose":"add_friend","expires_in_seconds":60}`))
@@ -3447,9 +3722,14 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1)
mockStore.EXPECT().GetUidString().Return("request-1")
oldRelations := store.Relations
oldProfiles := store.Profiles
relations := &testRelationsStore{}
store.Relations = relations
defer func() { store.Relations = oldRelations }()
store.Profiles = &testProfilesStore{}
defer func() {
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`))
@@ -3643,6 +3923,7 @@ func TestServeIMUserProfile(t *testing.T) {
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldUsers := store.Users
oldRelations := store.Relations
oldProfiles := store.Profiles
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
peer := types.Uid(2)
users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{
@@ -3659,9 +3940,11 @@ func TestServeIMUserProfile(t *testing.T) {
Relationship: types.RelationshipFriend,
CanSend: true,
}}
store.Profiles = &testProfilesStore{}
defer func() {
store.Users = oldUsers
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil)