mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
392 lines
15 KiB
Go
392 lines
15 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"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/plugin/plugintest/mock"
|
|
"github.com/hanzoteam/server/server/public/shared/mlog"
|
|
"github.com/hanzoteam/server/server/v8/channels/store/storetest/mocks"
|
|
)
|
|
|
|
func setupSendTest(t *testing.T) (*Service, *mocks.Store, *mocks.ChannelMemberHistoryStore, *mocks.SharedChannelStore, *mocks.UserStore, *mocks.RemoteClusterStore) {
|
|
t.Helper()
|
|
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockServer.On("GetMetrics").Return(nil)
|
|
mockServer.On("GetRemoteClusterService").Return(nil)
|
|
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{
|
|
server: mockServer,
|
|
app: mockApp,
|
|
}
|
|
|
|
mockStore := &mocks.Store{}
|
|
mockCMHStore := &mocks.ChannelMemberHistoryStore{}
|
|
mockSharedChannelStore := &mocks.SharedChannelStore{}
|
|
mockUserStore := &mocks.UserStore{}
|
|
mockRCStore := &mocks.RemoteClusterStore{}
|
|
|
|
mockStore.On("ChannelMemberHistory").Return(mockCMHStore)
|
|
mockStore.On("SharedChannel").Return(mockSharedChannelStore)
|
|
mockStore.On("User").Return(mockUserStore)
|
|
mockStore.On("RemoteCluster").Return(mockRCStore)
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
|
|
mockConfig := model.Config{}
|
|
mockConfig.SetDefaults()
|
|
mockServer.On("Config").Return(&mockConfig)
|
|
|
|
return scs, mockStore, mockCMHStore, mockSharedChannelStore, mockUserStore, mockRCStore
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_NoChanges(t *testing.T) {
|
|
scs, _, mockCMHStore, _, _, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
|
|
Return([]*model.ChannelMemberHistory{}, nil)
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: model.NewId()},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, sd.membershipChanges)
|
|
assert.Equal(t, int64(0), sd.resultNextMembershipCursor)
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_DeduplicatesJoinLeaveRejoin(t *testing.T) {
|
|
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
remoteID := model.NewId()
|
|
userID := model.NewId()
|
|
|
|
// User joined at 1000, left at 2000, rejoined at 3000
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
|
|
Return([]*model.ChannelMemberHistory{
|
|
{ChannelId: channelID, UserId: userID, JoinTime: 1000, LeaveTime: new(int64(2000))},
|
|
{ChannelId: channelID, UserId: userID, JoinTime: 3000, LeaveTime: nil},
|
|
}, nil)
|
|
|
|
// User profile for the add — local user (no RemoteId) so shouldUserSync includes it
|
|
user := &model.User{Id: userID}
|
|
mockUserStore.On("Get", mock.Anything, userID).Return(user, nil)
|
|
|
|
// shouldUserSync needs SharedChannelUser lookup
|
|
mockSCStore.On("GetSingleUser", userID, channelID, remoteID).
|
|
Return(nil, ¬FoundError{})
|
|
mockSCStore.On("SaveUser", mock.AnythingOfType("*model.SharedChannelUser")).
|
|
Return(&model.SharedChannelUser{}, nil)
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
|
|
// Should deduplicate to 1 entry: user is currently a member (rejoin at 3000 > leave at 2000)
|
|
require.Len(t, sd.membershipChanges, 1)
|
|
assert.Equal(t, userID, sd.membershipChanges[0].UserId)
|
|
assert.True(t, sd.membershipChanges[0].IsAdd, "user rejoined so should be IsAdd=true")
|
|
assert.Equal(t, int64(3000), sd.membershipChanges[0].ChangeTime)
|
|
assert.Equal(t, int64(3000), sd.resultNextMembershipCursor)
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_DeduplicatesJoinThenLeave(t *testing.T) {
|
|
scs, _, mockCMHStore, _, mockUserStore, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
remoteID := model.NewId()
|
|
userID := model.NewId()
|
|
|
|
// User joined at 1000, then left at 2000
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
|
|
Return([]*model.ChannelMemberHistory{
|
|
{ChannelId: channelID, UserId: userID, JoinTime: 1000, LeaveTime: new(int64(2000))},
|
|
}, nil)
|
|
|
|
// User profile needed for remote-origin filtering
|
|
mockUserStore.On("Get", mock.Anything, userID).Return(&model.User{Id: userID}, nil)
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, sd.membershipChanges, 1)
|
|
assert.False(t, sd.membershipChanges[0].IsAdd, "user left so should be IsAdd=false")
|
|
assert.Equal(t, int64(2000), sd.membershipChanges[0].ChangeTime)
|
|
assert.Equal(t, int64(2000), sd.resultNextMembershipCursor)
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_MultipleUsers(t *testing.T) {
|
|
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
remoteID := model.NewId()
|
|
user1 := model.NewId()
|
|
user2 := model.NewId()
|
|
user3 := model.NewId()
|
|
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
|
|
Return([]*model.ChannelMemberHistory{
|
|
{ChannelId: channelID, UserId: user1, JoinTime: 1000, LeaveTime: nil}, // joined, still member
|
|
{ChannelId: channelID, UserId: user2, JoinTime: 2000, LeaveTime: new(int64(3000))}, // joined then left
|
|
{ChannelId: channelID, UserId: user3, JoinTime: 4000, LeaveTime: nil}, // joined, still member
|
|
}, nil)
|
|
|
|
// User profiles for all users — needed for remote-origin filtering
|
|
for _, uid := range []string{user1, user2, user3} {
|
|
u := &model.User{Id: uid}
|
|
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
|
|
}
|
|
|
|
// shouldUserSync mocks for adds (user1 and user3) — users are local (no RemoteId)
|
|
// so shouldUserSync won't filter them out for the target remote
|
|
for _, uid := range []string{user1, user3} {
|
|
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, ¬FoundError{})
|
|
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
|
|
Return(&model.SharedChannelUser{}, nil)
|
|
}
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, sd.membershipChanges, 3)
|
|
|
|
// Build a map for easy lookup
|
|
changeMap := make(map[string]*model.MembershipChangeMsg)
|
|
for _, mc := range sd.membershipChanges {
|
|
changeMap[mc.UserId] = mc
|
|
}
|
|
|
|
assert.True(t, changeMap[user1].IsAdd, "user1 is still a member")
|
|
assert.False(t, changeMap[user2].IsAdd, "user2 left")
|
|
assert.True(t, changeMap[user3].IsAdd, "user3 is still a member")
|
|
|
|
// Max cursor should be 4000 (user3 join)
|
|
assert.Equal(t, int64(4000), sd.resultNextMembershipCursor)
|
|
|
|
// Only user1 and user3 should be in the users map (adds only)
|
|
assert.Contains(t, sd.users, user1)
|
|
assert.NotContains(t, sd.users, user2, "user2 left, should not be in users map")
|
|
assert.Contains(t, sd.users, user3)
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_SetsRepeatWhenLimitHit(t *testing.T) {
|
|
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
remoteID := model.NewId()
|
|
|
|
// Return exactly as many results as the limit (batch size)
|
|
batchSize := scs.GetMemberSyncBatchSize()
|
|
histories := make([]*model.ChannelMemberHistory, batchSize)
|
|
for i := range batchSize {
|
|
uid := model.NewId()
|
|
histories[i] = &model.ChannelMemberHistory{
|
|
ChannelId: channelID,
|
|
UserId: uid,
|
|
JoinTime: int64(1000 + i),
|
|
LeaveTime: nil,
|
|
}
|
|
u := &model.User{Id: uid}
|
|
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
|
|
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, ¬FoundError{})
|
|
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
|
|
Return(&model.SharedChannelUser{}, nil)
|
|
}
|
|
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), batchSize).
|
|
Return(histories, nil)
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
assert.True(t, sd.resultRepeat, "should set resultRepeat when limit is hit")
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_CursorFromSCR(t *testing.T) {
|
|
scs, _, mockCMHStore, _, _, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
remoteID := model.NewId()
|
|
|
|
// Verify that LastMembersSyncAt from SCR is used as the cursor
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(5000), mock.AnythingOfType("int")).
|
|
Return([]*model.ChannelMemberHistory{}, nil)
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 5000},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
mockCMHStore.AssertCalled(t, "GetMembershipChanges", channelID, int64(5000), mock.AnythingOfType("int"))
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_RepeatedTimestampsAtBoundary(t *testing.T) {
|
|
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
remoteID := model.NewId()
|
|
batchSize := scs.GetMemberSyncBatchSize()
|
|
|
|
// Create batch where the last entries share the same JoinTime (boundary timestamp)
|
|
boundaryTime := int64(5000)
|
|
histories := make([]*model.ChannelMemberHistory, batchSize)
|
|
for i := range batchSize {
|
|
uid := model.NewId()
|
|
joinTime := int64(1000 + i)
|
|
// Last 3 entries share the same timestamp
|
|
if i >= batchSize-3 {
|
|
joinTime = boundaryTime
|
|
}
|
|
histories[i] = &model.ChannelMemberHistory{
|
|
ChannelId: channelID,
|
|
UserId: uid,
|
|
JoinTime: joinTime,
|
|
LeaveTime: nil,
|
|
}
|
|
u := &model.User{Id: uid}
|
|
mockUserStore.On("Get", mock.Anything, uid).Return(u, nil)
|
|
mockSCStore.On("GetSingleUser", uid, channelID, remoteID).Return(nil, ¬FoundError{})
|
|
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == uid })).
|
|
Return(&model.SharedChannelUser{}, nil)
|
|
}
|
|
|
|
// First fetch returns full batch (hits limit)
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), batchSize).
|
|
Return(histories, nil)
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
assert.True(t, sd.resultRepeat, "should signal more data when limit hit")
|
|
assert.Len(t, sd.membershipChanges, batchSize, "all entries in batch should be processed")
|
|
assert.Equal(t, boundaryTime, sd.resultNextMembershipCursor, "cursor should be at boundary timestamp")
|
|
|
|
// Second fetch uses the boundary timestamp as cursor (GtOrEq means events
|
|
// AT boundaryTime will be re-fetched, but deduplication handles this)
|
|
extraUser := model.NewId()
|
|
mockCMHStore.On("GetMembershipChanges", channelID, boundaryTime, batchSize).
|
|
Return([]*model.ChannelMemberHistory{
|
|
// Re-fetched rows at boundary (already processed — dedup will handle)
|
|
histories[batchSize-3],
|
|
histories[batchSize-2],
|
|
histories[batchSize-1],
|
|
// New row beyond the boundary
|
|
{ChannelId: channelID, UserId: extraUser, JoinTime: boundaryTime + 1, LeaveTime: nil},
|
|
}, nil)
|
|
|
|
extraUserObj := &model.User{Id: extraUser}
|
|
mockUserStore.On("Get", mock.Anything, extraUser).Return(extraUserObj, nil)
|
|
mockSCStore.On("GetSingleUser", extraUser, channelID, remoteID).Return(nil, ¬FoundError{})
|
|
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == extraUser })).
|
|
Return(&model.SharedChannelUser{}, nil)
|
|
|
|
sd2 := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: boundaryTime},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err = scs.fetchMembershipsForSync(sd2)
|
|
require.NoError(t, err)
|
|
// Should have 4 entries (3 re-fetched + 1 new) — dedup at the caller level
|
|
// handles duplicate user IDs across batches
|
|
assert.Len(t, sd2.membershipChanges, 4, "should include re-fetched boundary rows and new row")
|
|
assert.Equal(t, boundaryTime+1, sd2.resultNextMembershipCursor, "cursor should advance past boundary")
|
|
}
|
|
|
|
func TestFetchMembershipsForSync_SkipsUsersFromTargetRemote(t *testing.T) {
|
|
scs, _, mockCMHStore, mockSCStore, mockUserStore, _ := setupSendTest(t)
|
|
|
|
channelID := model.NewId()
|
|
remoteID := model.NewId()
|
|
localUser := model.NewId() // local to sender, will be synced
|
|
targetRemoteUser := model.NewId() // originated from target remote, should be skipped
|
|
|
|
mockCMHStore.On("GetMembershipChanges", channelID, int64(0), mock.AnythingOfType("int")).
|
|
Return([]*model.ChannelMemberHistory{
|
|
{ChannelId: channelID, UserId: localUser, JoinTime: 1000, LeaveTime: nil},
|
|
{ChannelId: channelID, UserId: targetRemoteUser, JoinTime: 2000, LeaveTime: nil},
|
|
}, nil)
|
|
|
|
// Local user — no RemoteId, should pass the filter
|
|
mockUserStore.On("Get", mock.Anything, localUser).Return(&model.User{Id: localUser}, nil)
|
|
mockSCStore.On("GetSingleUser", localUser, channelID, remoteID).Return(nil, ¬FoundError{})
|
|
mockSCStore.On("SaveUser", mock.MatchedBy(func(scu *model.SharedChannelUser) bool { return scu.UserId == localUser })).
|
|
Return(&model.SharedChannelUser{}, nil)
|
|
|
|
// User from target remote — should be filtered out
|
|
mockUserStore.On("Get", mock.Anything, targetRemoteUser).Return(&model.User{Id: targetRemoteUser, RemoteId: &remoteID}, nil)
|
|
|
|
sd := &syncData{
|
|
task: syncTask{channelID: channelID},
|
|
rc: &model.RemoteCluster{RemoteId: remoteID},
|
|
scr: &model.SharedChannelRemote{LastMembersSyncAt: 0},
|
|
users: make(map[string]*model.User),
|
|
}
|
|
|
|
err := scs.fetchMembershipsForSync(sd)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, sd.membershipChanges, 1, "should only include local user, not user from target remote")
|
|
assert.Equal(t, localUser, sd.membershipChanges[0].UserId)
|
|
assert.True(t, sd.membershipChanges[0].IsAdd)
|
|
}
|
|
|
|
// notFoundError implements the errNotFound interface used by the sharedchannel package
|
|
type notFoundError struct{}
|
|
|
|
func (e *notFoundError) Error() string { return "not found" }
|
|
func (e *notFoundError) IsErrNotFound() bool { return true }
|