Files
Doug LauderandGitHub 2e82c160f4 MM-67616: Refactor shared channel membership sync to use ChannelMemberHistory (#35619)
* Refactor shared channel membership sync to use ChannelMemberHistory (MM-67616)

Replace the trigger-time membership sync mechanism with a cursor-based
approach using ChannelMemberHistory, aligning membership sync with the
established pattern used by posts and reactions.

Previously, membership changes were built into SyncMsg at trigger time
and sent via a separate TopicChannelMembership code path. This meant
removals were lost if a remote was offline, since ChannelMembers
hard-deletes rows.

Now, membership changes are fetched from ChannelMemberHistory at sync
time using the LastMembersSyncAt cursor, detecting both joins and leaves
reliably. The data flows through the normal syncForRemote pipeline
alongside posts, reactions, and other sync data.

Key changes:
  - Add GetMembershipChanges store method for ChannelMemberHistory
  - Add fetchMembershipsForSync and sendMembershipSyncData to sync pipeline
  - Replace HandleMembershipChange with NotifyMembershipChanged (trigger-only)
  - Remove conflict detection (idempotent add/remove resolves naturally)
  - Remove per-user membership tracking (GetUserChanges, UpdateUserLastMembershipSyncAt)
  - Add MembershipErrors to SyncResponse
  - Keep TopicChannelMembership receiver for one release cycle (backward compat)
2026-03-23 10:12:17 -04:00

78 lines
1.7 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestService() *Service {
return &Service{
changeSignal: make(chan struct{}, 1),
tasks: make(map[string]syncTask),
}
}
func TestAddTask_OriginRemoteIDMerge(t *testing.T) {
tests := []struct {
name string
firstOrigin string
secondOrigin string
expectedOrigin string
}{
{
name: "same remote origin is preserved",
firstOrigin: "remote-A",
secondOrigin: "remote-A",
expectedOrigin: "remote-A",
},
{
name: "local then remote clears origin",
firstOrigin: "",
secondOrigin: "remote-A",
expectedOrigin: "",
},
{
name: "remote then local clears origin",
firstOrigin: "remote-A",
secondOrigin: "",
expectedOrigin: "",
},
{
name: "different remotes clears origin",
firstOrigin: "remote-A",
secondOrigin: "remote-B",
expectedOrigin: "",
},
{
name: "both local stays empty",
firstOrigin: "",
secondOrigin: "",
expectedOrigin: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
scs := newTestService()
channelID := "channel-1"
first := newSyncTask(channelID, "", "", nil, nil)
first.originRemoteID = tc.firstOrigin
scs.addTask(first)
second := newSyncTask(channelID, "", "", nil, nil)
second.originRemoteID = tc.secondOrigin
scs.addTask(second)
merged, ok := scs.tasks[first.id]
require.True(t, ok, "task should exist")
assert.Equal(t, tc.expectedOrigin, merged.originRemoteID)
})
}
}