mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* MM-69792: Recover shared channel sync after a brief remote outage When a remote went offline for less than the 5-minute IsOnline() threshold, sync recovery never ran. IsOnline() never flipped, so no connection-state-change event fired and ForceSyncForRemote was never triggered. Posts created during the outage stayed unsynced until the next organic change in the channel. The root cause was that send failures are reported asynchronously through a callback that sendSyncMsgToRemote discarded. On a failed send syncForRemote still returned nil, so processTask saw success: the retry loop and the failure-notification hook were effectively dead code for delivery failures. Changes: - sendSyncMsgToRemote now propagates the delivery error reported via the callback instead of swallowing it. A failed send is retried rather than silently dropped. This applies to every sync type (posts, users, memberships, reactions, statuses); the success path is unchanged. - remotecluster tracks a per-remote "sync failed since last ping" flag (NotifySyncFailed). On the next successful ping, PingNow fires a connection-state-change event even when IsOnline() never flipped, driving a single ForceSyncForRemote on recovery. - Sync retries are now spaced by SyncRetryDelay (15s) instead of retrying immediately, giving a short outage time to recover and guaranteeing a failed ping before retries exhaust. Also adds a post/offline-recovery case to the sharedchannel-test integration tool (stop and restart Server B mid-run) and disables metrics on the two test servers so they do not collide on the metrics port when the loaded config has metrics enabled. * fix linter error * Fix Rabbit nit * fix CI * Delete pr37499-fix-findings-1-and-4.md * address review comments * MM-69792: Keep a per-remote retry task when a shared channel sync fans out A sync task with no specific remote (remoteID == "") fans out to every remote sharing the channel. On failure each remote's retry was a copy of the original task that still carried the original, remote-less task id. Because addTask merges on task id, all of those per-remote retries collapsed into a single entry, so only one failed remote was ever retried and the rest were silently dropped. Delivery failures were partly masked by the ping-driven ForceSyncForRemote recovery, but non-delivery errors for the other remotes had no path back. Give each per-remote retry a remote-specific id (task.id + remoteId) when the originating task had no remote, so every failed remote keeps its own retry task. Single-remote tasks already have a remote-specific id and are left untouched, since recomputing would grow the id on each retry. Also harden the tests: assert the retry is scheduled the full SyncRetryDelay out (no tolerance), assert the result callback is not invoked on delivery failures or unconfirmed responses (so the cursor stays un-advanced), and add a regression test covering fan-out to multiple failing remotes. * fix one more pre-existing edge case. * fix linter
527 lines
21 KiB
Go
527 lines
21 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package sharedchannel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
|
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
|
|
)
|
|
|
|
var (
|
|
mockTypeChannel = mock.AnythingOfType("*model.Channel")
|
|
mockTypeUser = mock.AnythingOfType("*model.User")
|
|
mockTypeString = mock.AnythingOfType("string")
|
|
mockTypeReqContext = mock.AnythingOfType("*request.Context")
|
|
mockTypeContext = mock.MatchedBy(func(ctx context.Context) bool { return true })
|
|
)
|
|
|
|
// stubRemoteClusterService is a no-op implementation of RemoteClusterServiceIFace for tests
|
|
// that only need to satisfy the non-nil check in SendChannelInvite (remote offline path).
|
|
type stubRemoteClusterService struct{}
|
|
|
|
func (s *stubRemoteClusterService) Shutdown() error { return nil }
|
|
func (s *stubRemoteClusterService) Start() error { return nil }
|
|
func (s *stubRemoteClusterService) Active() bool { return false }
|
|
func (s *stubRemoteClusterService) AddTopicListener(topic string, listener remotecluster.TopicListener) string {
|
|
return ""
|
|
}
|
|
func (s *stubRemoteClusterService) RemoveTopicListener(listenerId string) {}
|
|
func (s *stubRemoteClusterService) AddConnectionStateListener(listener remotecluster.ConnectionStateListener) string {
|
|
return ""
|
|
}
|
|
func (s *stubRemoteClusterService) RemoveConnectionStateListener(listenerId string) {}
|
|
func (s *stubRemoteClusterService) SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f remotecluster.SendMsgResultFunc) error {
|
|
return nil
|
|
}
|
|
func (s *stubRemoteClusterService) SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp remotecluster.ReaderProvider, f remotecluster.SendFileResultFunc) error {
|
|
return nil
|
|
}
|
|
func (s *stubRemoteClusterService) SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider remotecluster.ProfileImageProvider, f remotecluster.SendProfileImageResultFunc) error {
|
|
return nil
|
|
}
|
|
func (s *stubRemoteClusterService) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubRemoteClusterService) ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) remotecluster.Response {
|
|
return remotecluster.Response{}
|
|
}
|
|
func (s *stubRemoteClusterService) ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubRemoteClusterService) PingNow(rc *model.RemoteCluster) {}
|
|
func (s *stubRemoteClusterService) NotifySyncFailed(remoteId string) {}
|
|
|
|
var _ remotecluster.RemoteClusterServiceIFace = (*stubRemoteClusterService)(nil)
|
|
|
|
// setupMockServerWithConfig sets up the standard mocks that all tests need
|
|
func setupMockServerWithConfig(mockServer *MockServerIface) {
|
|
mockConfig := model.Config{}
|
|
mockConfig.SetDefaults()
|
|
mockServer.On("Config").Return(&mockConfig)
|
|
|
|
mockServer.On("GetRemoteClusterService").Return(nil)
|
|
}
|
|
|
|
func TestOnReceiveChannelInvite(t *testing.T) {
|
|
t.Run("when msg payload is empty, it does nothing", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{
|
|
server: mockServer,
|
|
app: mockApp,
|
|
}
|
|
|
|
mockStore := &mocks.Store{}
|
|
mockServer = scs.server.(*MockServerIface)
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
|
|
remoteCluster := &model.RemoteCluster{}
|
|
msg := model.RemoteClusterMsg{}
|
|
|
|
err := scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
|
require.NoError(t, err)
|
|
mockStore.AssertNotCalled(t, "Channel")
|
|
})
|
|
|
|
t.Run("when invitation prescribes a readonly channel, it does create a readonly channel", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{
|
|
server: mockServer,
|
|
app: mockApp,
|
|
}
|
|
|
|
mockStore := &mocks.Store{}
|
|
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
|
|
invitation := channelInviteMsg{
|
|
ChannelId: model.NewId(),
|
|
TeamId: model.NewId(),
|
|
ReadOnly: true,
|
|
Type: model.ChannelTypeOpen,
|
|
}
|
|
payload, err := json.Marshal(invitation)
|
|
require.NoError(t, err)
|
|
|
|
msg := model.RemoteClusterMsg{
|
|
Payload: payload,
|
|
}
|
|
mockChannelStore := mocks.ChannelStore{}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
channel := &model.Channel{
|
|
Id: invitation.ChannelId,
|
|
TeamId: invitation.TeamId,
|
|
Type: invitation.Type,
|
|
CreatorId: model.NewId(),
|
|
}
|
|
|
|
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
|
|
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, &store.ErrNotFound{})
|
|
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
|
|
mockSharedChannelStore.On("SaveRemote", mock.Anything).Return(nil, nil)
|
|
mockStore.On("Channel").Return(&mockChannelStore)
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
setupMockServerWithConfig(mockServer)
|
|
|
|
createPostPermission := model.ChannelModeratedPermissionsMap[model.PermissionCreatePost.Id]
|
|
createReactionPermission := model.ChannelModeratedPermissionsMap[model.PermissionAddReaction.Id]
|
|
updateMap := model.ChannelModeratedRolesPatch{
|
|
Guests: new(false),
|
|
Members: new(false),
|
|
}
|
|
|
|
mockApp.On("CreateChannelWithUser", mockTypeReqContext, mockTypeChannel, mockTypeString).Return(channel, nil)
|
|
|
|
readonlyChannelModerations := []*model.ChannelModerationPatch{
|
|
{
|
|
Name: &createPostPermission,
|
|
Roles: &updateMap,
|
|
},
|
|
{
|
|
Name: &createReactionPermission,
|
|
Roles: &updateMap,
|
|
},
|
|
}
|
|
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, readonlyChannelModerations).Return(nil, nil).Maybe()
|
|
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
|
|
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
|
|
defer mockApp.AssertExpectations(t)
|
|
|
|
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("when invitation prescribes a readonly channel and readonly update fails, it returns an error", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{
|
|
server: mockServer,
|
|
app: mockApp,
|
|
}
|
|
|
|
mockStore := &mocks.Store{}
|
|
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test2"}
|
|
invitation := channelInviteMsg{
|
|
ChannelId: model.NewId(),
|
|
TeamId: model.NewId(),
|
|
ReadOnly: true,
|
|
Type: "0",
|
|
}
|
|
payload, err := json.Marshal(invitation)
|
|
require.NoError(t, err)
|
|
|
|
msg := model.RemoteClusterMsg{
|
|
Payload: payload,
|
|
}
|
|
mockChannelStore := mocks.ChannelStore{}
|
|
channel := &model.Channel{
|
|
Id: invitation.ChannelId,
|
|
}
|
|
mockTeamStore := mocks.TeamStore{}
|
|
team := &model.Team{
|
|
Id: model.NewId(),
|
|
}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
|
|
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
|
|
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, &store.ErrNotFound{})
|
|
mockTeamStore.On("GetAllPage", 0, 1, mock.Anything).Return([]*model.Team{team}, nil)
|
|
mockStore.On("Channel").Return(&mockChannelStore)
|
|
mockStore.On("Team").Return(&mockTeamStore)
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
|
|
mockServer = scs.server.(*MockServerIface)
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
appErr := model.NewAppError("foo", "bar", nil, "boom", http.StatusBadRequest)
|
|
|
|
mockApp.On("CreateChannelWithUser", mockTypeReqContext, mockTypeChannel, mockTypeString).Return(channel, nil)
|
|
mockApp.On("PatchChannelModerationsForChannel", mock.Anything, channel, mock.Anything).Return(nil, appErr)
|
|
defer mockApp.AssertExpectations(t)
|
|
|
|
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
|
require.Error(t, err)
|
|
assert.Equal(t, fmt.Sprintf("cannot make channel readonly `%s`: foo: bar, boom", invitation.ChannelId), err.Error())
|
|
})
|
|
|
|
t.Run("When invitation points to a deleted shared channel remote", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{
|
|
server: mockServer,
|
|
app: mockApp,
|
|
}
|
|
|
|
mockStore := &mocks.Store{}
|
|
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
|
|
invitation := channelInviteMsg{
|
|
ChannelId: model.NewId(),
|
|
TeamId: model.NewId(),
|
|
Type: model.ChannelTypeOpen,
|
|
}
|
|
payload, err := json.Marshal(invitation)
|
|
require.NoError(t, err)
|
|
|
|
msg := model.RemoteClusterMsg{
|
|
Payload: payload,
|
|
}
|
|
mockChannelStore := mocks.ChannelStore{}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
channel := &model.Channel{
|
|
Id: invitation.ChannelId,
|
|
TeamId: invitation.TeamId,
|
|
Type: invitation.Type,
|
|
CreatorId: model.NewId(),
|
|
}
|
|
sharedChannelRemote := &model.SharedChannelRemote{
|
|
ChannelId: invitation.ChannelId,
|
|
RemoteId: remoteCluster.RemoteId,
|
|
DeleteAt: 1234,
|
|
}
|
|
|
|
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, mock.Anything).Return(sharedChannelRemote, nil)
|
|
mockChannelStore.On("Get", invitation.ChannelId, true).Return(channel, nil)
|
|
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
|
|
mockSharedChannelStore.On("UpdateRemote", mock.Anything).Return(nil, nil)
|
|
mockStore.On("Channel").Return(&mockChannelStore)
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
setupMockServerWithConfig(mockServer)
|
|
|
|
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
|
|
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
|
|
defer mockApp.AssertExpectations(t)
|
|
|
|
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("When invitation points to an existing shared channel remote", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{
|
|
server: mockServer,
|
|
app: mockApp,
|
|
}
|
|
|
|
mockStore := &mocks.Store{}
|
|
remoteCluster := &model.RemoteCluster{RemoteId: model.NewId(), Name: "test", DefaultTeamId: model.NewId()}
|
|
invitation := channelInviteMsg{
|
|
ChannelId: model.NewId(),
|
|
TeamId: model.NewId(),
|
|
Type: model.ChannelTypeOpen,
|
|
}
|
|
payload, err := json.Marshal(invitation)
|
|
require.NoError(t, err)
|
|
|
|
msg := model.RemoteClusterMsg{
|
|
Payload: payload,
|
|
}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
sharedChannelRemote := &model.SharedChannelRemote{
|
|
ChannelId: invitation.ChannelId,
|
|
RemoteId: remoteCluster.RemoteId,
|
|
DeleteAt: 0,
|
|
}
|
|
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(sharedChannelRemote, nil)
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
|
|
defer mockApp.AssertExpectations(t)
|
|
|
|
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("DM channels", func(t *testing.T) {
|
|
var testRemoteID = model.NewId()
|
|
testCases := []struct {
|
|
desc string
|
|
user1 *model.User
|
|
user2 *model.User
|
|
canSee bool
|
|
expectSuccess bool
|
|
user2InDB bool
|
|
user2InParticipants bool
|
|
}{
|
|
{"valid users", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, true, true, false},
|
|
{"swapped users", &model.User{Id: model.NewId()}, &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, true, true, true, false},
|
|
{"two remotes", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, true, false, true, false},
|
|
{"two locals", &model.User{Id: model.NewId()}, &model.User{Id: model.NewId()}, true, false, true, false},
|
|
{"can't see", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, false, false, true, false},
|
|
{"invalid remoteid", &model.User{Id: model.NewId(), RemoteId: new("bogus")}, &model.User{Id: model.NewId()}, true, false, true, false},
|
|
{"user2 not in DB but in participants", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, true, false, true},
|
|
{"user2 not in DB and not in participants", &model.User{Id: model.NewId(), RemoteId: &testRemoteID}, &model.User{Id: model.NewId()}, true, false, false, false},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{
|
|
server: mockServer,
|
|
app: mockApp,
|
|
}
|
|
|
|
mockStore := &mocks.Store{}
|
|
remoteCluster := &model.RemoteCluster{RemoteId: testRemoteID, Name: "test3", CreatorId: model.NewId()}
|
|
invitation := channelInviteMsg{
|
|
ChannelId: model.NewId(),
|
|
TeamId: model.NewId(),
|
|
ReadOnly: false,
|
|
Type: model.ChannelTypeDirect,
|
|
DirectParticipantIDs: []string{tc.user1.Id, tc.user2.Id},
|
|
}
|
|
|
|
// Add participants to the invitation if needed
|
|
if tc.user2InParticipants {
|
|
invitation.DirectParticipants = append(invitation.DirectParticipants, tc.user2)
|
|
}
|
|
|
|
payload, err := json.Marshal(invitation)
|
|
require.NoError(t, err)
|
|
|
|
msg := model.RemoteClusterMsg{
|
|
Payload: payload,
|
|
}
|
|
mockChannelStore := mocks.ChannelStore{}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
channel := &model.Channel{
|
|
Id: invitation.ChannelId,
|
|
CreatorId: model.NewId(),
|
|
}
|
|
|
|
mockUserStore := mocks.UserStore{}
|
|
mockUserStore.On("Get", mockTypeContext, tc.user1.Id).
|
|
Return(tc.user1, nil)
|
|
if tc.user2InDB {
|
|
mockUserStore.On("Get", mockTypeContext, tc.user2.Id).
|
|
Return(tc.user2, nil)
|
|
} else {
|
|
mockUserStore.On("Get", mockTypeContext, tc.user2.Id).
|
|
Return(nil, &store.ErrNotFound{})
|
|
}
|
|
|
|
if tc.user2InParticipants {
|
|
mockUserStore.On("Save", mock.AnythingOfType("*request.Context"),
|
|
mock.MatchedBy(func(u *model.User) bool {
|
|
return u.Id == tc.user2.Id
|
|
})).Return(tc.user2, nil)
|
|
}
|
|
|
|
mockChannelStore.On("Get", invitation.ChannelId, true).Return(nil, errors.New("boom"))
|
|
mockChannelStore.On("GetByName", "", mockTypeString, true).Return(nil, &store.ErrNotFound{})
|
|
|
|
mockSharedChannelStore.On("GetRemoteByIds", invitation.ChannelId, remoteCluster.RemoteId).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
|
|
mockSharedChannelStore.On("Save", mock.Anything).Return(nil, nil)
|
|
mockSharedChannelStore.On("SaveRemote", mock.Anything).Return(nil, nil)
|
|
mockStore.On("Channel").Return(&mockChannelStore)
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
mockStore.On("User").Return(&mockUserStore)
|
|
|
|
mockServer = scs.server.(*MockServerIface)
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
setupMockServerWithConfig(mockServer)
|
|
|
|
mockApp.On("GetOrCreateDirectChannel", mockTypeReqContext, mockTypeString, mockTypeString, mock.AnythingOfType("model.ChannelOption")).
|
|
Return(channel, nil).Maybe()
|
|
mockApp.On("UserCanSeeOtherUser", mockTypeReqContext, mockTypeString, mockTypeString).Return(tc.canSee, nil).Maybe()
|
|
mockApp.On("NotifySharedChannelUserUpdate", mockTypeUser).Return().Maybe()
|
|
if tc.expectSuccess {
|
|
mockApp.On("GetSystemBot", mockTypeReqContext).Return(&model.Bot{UserId: model.NewId()}, nil).Once()
|
|
mockApp.On("CreatePost", mockTypeReqContext, mock.AnythingOfType("*model.Post"), mockTypeChannel, mock.AnythingOfType("model.CreatePostFlags")).Return(&model.Post{Id: model.NewId()}, false, nil).Once()
|
|
}
|
|
|
|
defer mockApp.AssertExpectations(t)
|
|
|
|
err = scs.onReceiveChannelInvite(msg, remoteCluster, nil)
|
|
require.Equal(t, tc.expectSuccess, err == nil)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSendChannelInvite_ExistingSharedConnection(t *testing.T) {
|
|
channelID := model.NewId()
|
|
userID := model.NewId()
|
|
remoteID := model.NewId()
|
|
sharedChannel := &model.SharedChannel{ChannelId: channelID}
|
|
channel := &model.Channel{Id: channelID}
|
|
// Remote with LastPingAt 0 is offline (IsOnline() returns false)
|
|
rc := &model.RemoteCluster{RemoteId: remoteID, Name: "test-remote", DisplayName: "Test Remote", LastPingAt: 0}
|
|
|
|
t.Run("when remote is offline and existing connection is not soft deleted, returns ErrChannelAlreadyShared", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{server: mockServer, app: mockApp}
|
|
|
|
mockStore := &mocks.Store{}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
existingScr := &model.SharedChannelRemote{
|
|
ChannelId: channelID,
|
|
RemoteId: remoteID,
|
|
DeleteAt: 0, // not soft deleted -> already connected
|
|
}
|
|
|
|
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
|
|
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(existingScr, nil)
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
|
|
mockApp.On("SendEphemeralPost", mockTypeReqContext, userID, mock.MatchedBy(func(post *model.Post) bool {
|
|
return post != nil && post.ChannelId == channelID && post.Message != ""
|
|
})).Return(nil, true).Once()
|
|
|
|
err := scs.SendChannelInvite(channel, userID, rc)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, model.ErrChannelAlreadyShared)
|
|
mockApp.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("when remote is offline and existing connection is soft deleted, restores and succeeds", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{server: mockServer, app: mockApp}
|
|
|
|
mockStore := &mocks.Store{}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
existingScr := &model.SharedChannelRemote{
|
|
ChannelId: channelID,
|
|
RemoteId: remoteID,
|
|
DeleteAt: 12345, // soft deleted
|
|
}
|
|
|
|
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
|
|
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(existingScr, nil)
|
|
mockSharedChannelStore.On("UpdateRemote", mock.MatchedBy(func(scr *model.SharedChannelRemote) bool {
|
|
return scr.ChannelId == channelID && scr.RemoteId == remoteID && scr.DeleteAt == 0 && scr.CreatorId == userID &&
|
|
scr.IsInviteAccepted && !scr.IsInviteConfirmed && scr.LastMembersSyncAt == 0
|
|
})).Return(nil, nil).Once()
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
|
|
|
|
err := scs.SendChannelInvite(channel, userID, rc)
|
|
require.NoError(t, err)
|
|
mockSharedChannelStore.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("when remote is offline and no existing connection, saves new and succeeds", func(t *testing.T) {
|
|
mockServer := &MockServerIface{}
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
mockServer.On("Log").Return(logger)
|
|
mockApp := &MockAppIface{}
|
|
scs := &Service{server: mockServer, app: mockApp}
|
|
|
|
mockStore := &mocks.Store{}
|
|
mockSharedChannelStore := mocks.SharedChannelStore{}
|
|
|
|
mockSharedChannelStore.On("Get", channelID).Return(sharedChannel, nil)
|
|
mockSharedChannelStore.On("GetRemoteByIds", channelID, remoteID).Return(nil, store.NewErrNotFound("SharedChannelRemote", ""))
|
|
mockSharedChannelStore.On("SaveRemote", mock.MatchedBy(func(scr *model.SharedChannelRemote) bool {
|
|
return scr.ChannelId == channelID && scr.RemoteId == remoteID && scr.CreatorId == userID &&
|
|
scr.IsInviteAccepted && !scr.IsInviteConfirmed
|
|
})).Return(nil, nil).Once()
|
|
mockStore.On("SharedChannel").Return(&mockSharedChannelStore)
|
|
mockServer.On("GetStore").Return(mockStore)
|
|
mockServer.On("GetRemoteClusterService").Return(&stubRemoteClusterService{})
|
|
|
|
err := scs.SendChannelInvite(channel, userID, rc)
|
|
require.NoError(t, err)
|
|
mockSharedChannelStore.AssertExpectations(t)
|
|
})
|
|
}
|