MM-57326: [Shared Channels] Message priority, acknowledgement and persistent notifications need to be synced (#30736)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
fa1c77d9b0
Коммит
85391de22a
659
server/channels/api4/shared_channel_metadata_test.go
Обычный файл
659
server/channels/api4/shared_channel_metadata_test.go
Обычный файл
@@ -0,0 +1,659 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
|
||||
)
|
||||
|
||||
// setupTestEnvironment sets up a common test environment for shared channel metadata tests
|
||||
func setupTestEnvironment(t *testing.T) (*TestHelper, *sharedchannel.Service) {
|
||||
th := setupForSharedChannels(t).InitBasic()
|
||||
ss := th.App.Srv().Store()
|
||||
EnsureCleanState(t, th, ss)
|
||||
|
||||
// Set license with all enterprise features
|
||||
license := model.NewTestLicense()
|
||||
license.SkuShortName = model.LicenseShortSkuEnterprise
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
// Enable post priorities and persistent notifications
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.PostPriority = true
|
||||
*cfg.ServiceSettings.AllowPersistentNotifications = true
|
||||
*cfg.ServiceSettings.AllowPersistentNotificationsForGuests = true
|
||||
*cfg.ServiceSettings.PersistentNotificationMaxRecipients = 100
|
||||
})
|
||||
|
||||
// Verify license and settings
|
||||
require.NotNil(t, th.App.Srv().License(), "License should be active")
|
||||
postPriorityEnabled := *th.App.Config().ServiceSettings.PostPriority
|
||||
require.True(t, postPriorityEnabled, "Post priorities should be enabled")
|
||||
|
||||
// Get the shared channel service and cast to concrete type
|
||||
scsInterface := th.App.Srv().GetSharedChannelSyncService()
|
||||
service, ok := scsInterface.(*sharedchannel.Service)
|
||||
require.True(t, ok, "Expected sharedchannel.Service concrete type")
|
||||
require.True(t, service.Active(), "SharedChannel service should be active")
|
||||
|
||||
// Ensure services are running
|
||||
err := service.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
rcService := th.App.Srv().GetRemoteClusterService()
|
||||
if rcService != nil {
|
||||
_ = rcService.Start()
|
||||
if rc, ok := rcService.(*remotecluster.Service); ok {
|
||||
rc.SetActive(true)
|
||||
}
|
||||
require.True(t, rcService.Active(), "RemoteClusterService should be active")
|
||||
}
|
||||
|
||||
return th, service
|
||||
}
|
||||
|
||||
// createSharedChannelSetup creates a shared channel with remote cluster for testing
|
||||
func createSharedChannelSetup(t *testing.T, th *TestHelper, service *sharedchannel.Service, testServer *httptest.Server) (*model.Channel, *model.RemoteCluster) {
|
||||
// Create remote cluster
|
||||
selfCluster := &model.RemoteCluster{
|
||||
RemoteId: model.NewId(),
|
||||
Name: "test-cluster-" + model.NewId()[:8],
|
||||
SiteURL: testServer.URL,
|
||||
CreateAt: model.GetMillis(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
Token: model.NewId(),
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteToken: model.NewId(),
|
||||
}
|
||||
var err error
|
||||
selfCluster, err = th.App.Srv().Store().RemoteCluster().Save(selfCluster)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create channel with users
|
||||
testChannel := th.CreatePublicChannel()
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, testChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser2, testChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Create shared channel
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: testChannel.Id,
|
||||
TeamId: testChannel.TeamId,
|
||||
Home: true,
|
||||
ShareName: "test_sync_" + model.NewId()[:8],
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteId: selfCluster.RemoteId,
|
||||
}
|
||||
sc, err = th.App.ShareChannel(th.Context, sc)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create shared channel remote
|
||||
scr := &model.SharedChannelRemote{
|
||||
Id: model.NewId(),
|
||||
ChannelId: sc.ChannelId,
|
||||
CreatorId: sc.CreatorId,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
RemoteId: sc.RemoteId,
|
||||
}
|
||||
_, err = th.App.SaveSharedChannelRemote(scr)
|
||||
require.NoError(t, err)
|
||||
|
||||
return testChannel, selfCluster
|
||||
}
|
||||
|
||||
func TestSharedChannelPostMetadataSync(t *testing.T) {
|
||||
th, service := setupTestEnvironment(t)
|
||||
defer th.TearDown()
|
||||
|
||||
t.Run("Post Priority Metadata Self-Referential Sync", func(t *testing.T) {
|
||||
var syncedPosts []*model.Post
|
||||
|
||||
// Create test HTTP server using self-referential approach
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKResponse(w)
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
testChannel, selfCluster := createSharedChannelSetup(t, th, service, testServer)
|
||||
|
||||
// Initialize sync handler
|
||||
syncHandler := NewSelfReferentialSyncHandler(t, service, selfCluster)
|
||||
syncHandler.OnPostSync = func(post *model.Post) {
|
||||
t.Logf("Received synced post: ID=%s, Message=%s, HasMetadata=%v", post.Id, post.Message, post.Metadata != nil)
|
||||
if post.Metadata != nil && post.Metadata.Priority != nil {
|
||||
t.Logf("Post has priority metadata: Priority=%v, RequestedAck=%v, PersistentNotifications=%v",
|
||||
post.Metadata.Priority.Priority,
|
||||
post.Metadata.Priority.RequestedAck,
|
||||
post.Metadata.Priority.PersistentNotifications)
|
||||
}
|
||||
syncedPosts = append(syncedPosts, post)
|
||||
}
|
||||
|
||||
// Update test server to use the sync handler
|
||||
testServer.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncHandler.HandleRequest(w, r)
|
||||
})
|
||||
|
||||
// Create a local post with priority metadata
|
||||
originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: testChannel.Id,
|
||||
Message: "Test post with priority metadata @" + th.BasicUser2.Username,
|
||||
Metadata: &model.PostMetadata{
|
||||
Priority: &model.PostPriority{
|
||||
Priority: model.NewPointer(model.PostPriorityUrgent),
|
||||
RequestedAck: model.NewPointer(true),
|
||||
PersistentNotifications: model.NewPointer(true),
|
||||
},
|
||||
},
|
||||
}, testChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, originalPost)
|
||||
|
||||
// Trigger sync
|
||||
t.Logf("Triggering sync for channel: %s", testChannel.Id)
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Wait for sync completion using Eventually pattern
|
||||
require.Eventually(t, func() bool {
|
||||
return len(syncedPosts) >= 2
|
||||
}, 5*time.Second, 100*time.Millisecond, "Should receive synced posts via self-referential handler")
|
||||
|
||||
// Verify priority metadata is preserved through the complete sync flow
|
||||
t.Logf("Found %d synced posts", len(syncedPosts))
|
||||
syncedPost := syncedPosts[len(syncedPosts)-1]
|
||||
require.NotNil(t, syncedPost.Metadata, "Post metadata should be preserved")
|
||||
require.NotNil(t, syncedPost.Metadata.Priority, "Priority metadata should be preserved")
|
||||
assert.Equal(t, model.PostPriorityUrgent, *syncedPost.Metadata.Priority.Priority, "Priority should be preserved")
|
||||
assert.True(t, *syncedPost.Metadata.Priority.RequestedAck, "RequestedAck should be preserved")
|
||||
assert.True(t, *syncedPost.Metadata.Priority.PersistentNotifications, "PersistentNotifications should be preserved")
|
||||
})
|
||||
|
||||
t.Run("Post Acknowledgement Metadata Self-Referential Sync", func(t *testing.T) {
|
||||
EnsureCleanState(t, th, th.App.Srv().Store())
|
||||
var syncedPosts []*model.Post
|
||||
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKResponse(w)
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
testChannel, selfCluster := createSharedChannelSetup(t, th, service, testServer)
|
||||
|
||||
syncHandler := NewSelfReferentialSyncHandler(t, service, selfCluster)
|
||||
syncHandler.OnPostSync = func(post *model.Post) {
|
||||
syncedPosts = append(syncedPosts, post)
|
||||
}
|
||||
|
||||
testServer.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncHandler.HandleRequest(w, r)
|
||||
})
|
||||
|
||||
// Create post with acknowledgement request
|
||||
originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: testChannel.Id,
|
||||
Message: "Test post requesting acknowledgements @" + th.BasicUser2.Username,
|
||||
Metadata: &model.PostMetadata{
|
||||
Priority: &model.PostPriority{
|
||||
Priority: model.NewPointer(model.PostPriorityUrgent),
|
||||
RequestedAck: model.NewPointer(true),
|
||||
PersistentNotifications: model.NewPointer(false),
|
||||
},
|
||||
},
|
||||
}, testChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Add acknowledgement to the post
|
||||
ack := &model.PostAcknowledgement{
|
||||
PostId: originalPost.Id,
|
||||
UserId: th.BasicUser2.Id,
|
||||
ChannelId: originalPost.ChannelId,
|
||||
}
|
||||
_, appErr = th.App.SaveAcknowledgementForPostWithModel(th.Context, ack)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Trigger sync
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Wait for sync completion
|
||||
require.Eventually(t, func() bool {
|
||||
return len(syncedPosts) >= 2
|
||||
}, 5*time.Second, 100*time.Millisecond, "Should receive synced posts via self-referential handler")
|
||||
|
||||
// Verify acknowledgement metadata is preserved
|
||||
syncedPost := syncedPosts[len(syncedPosts)-1]
|
||||
require.NotNil(t, syncedPost.Metadata, "Post metadata should be preserved")
|
||||
require.NotNil(t, syncedPost.Metadata.Priority, "Priority metadata should be preserved")
|
||||
assert.Equal(t, model.PostPriorityUrgent, *syncedPost.Metadata.Priority.Priority, "Priority should be preserved")
|
||||
assert.True(t, *syncedPost.Metadata.Priority.RequestedAck, "RequestedAck should be preserved")
|
||||
assert.False(t, *syncedPost.Metadata.Priority.PersistentNotifications, "PersistentNotifications should be preserved")
|
||||
})
|
||||
|
||||
t.Run("Acknowledgement Count Sync Back to Sender", func(t *testing.T) {
|
||||
EnsureCleanState(t, th, th.App.Srv().Store())
|
||||
var syncedPosts []*model.Post
|
||||
var postIdToSync string
|
||||
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKResponse(w)
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
testChannel, selfCluster := createSharedChannelSetup(t, th, service, testServer)
|
||||
|
||||
syncHandler := NewSelfReferentialSyncHandler(t, service, selfCluster)
|
||||
syncHandler.OnPostSync = func(post *model.Post) {
|
||||
if post.Id == postIdToSync {
|
||||
t.Logf("Received sync for target post: ID=%s, HasAcks=%v", post.Id,
|
||||
post.Metadata != nil && post.Metadata.Acknowledgements != nil)
|
||||
if post.Metadata != nil && post.Metadata.Acknowledgements != nil {
|
||||
t.Logf("Acknowledgement count in sync: %d", len(post.Metadata.Acknowledgements))
|
||||
}
|
||||
}
|
||||
syncedPosts = append(syncedPosts, post)
|
||||
}
|
||||
|
||||
testServer.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncHandler.HandleRequest(w, r)
|
||||
})
|
||||
|
||||
// Create post with acknowledgement request
|
||||
originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: testChannel.Id,
|
||||
Message: "Test post for ack count sync @" + th.BasicUser2.Username,
|
||||
Metadata: &model.PostMetadata{
|
||||
Priority: &model.PostPriority{
|
||||
Priority: model.NewPointer(model.PostPriorityUrgent),
|
||||
RequestedAck: model.NewPointer(true),
|
||||
PersistentNotifications: model.NewPointer(false),
|
||||
},
|
||||
},
|
||||
}, testChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
postIdToSync = originalPost.Id
|
||||
|
||||
// Verify initial state - no acknowledgements
|
||||
acks, appErr := th.App.GetAcknowledgementsForPost(originalPost.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Empty(t, acks, "Should have no acknowledgements initially")
|
||||
|
||||
// Trigger initial sync
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Wait for initial sync
|
||||
require.Eventually(t, func() bool {
|
||||
return len(syncedPosts) >= 2
|
||||
}, 5*time.Second, 100*time.Millisecond, "Should complete initial sync")
|
||||
|
||||
// Add acknowledgement
|
||||
ackForSync := &model.PostAcknowledgement{
|
||||
PostId: originalPost.Id,
|
||||
UserId: th.BasicUser2.Id,
|
||||
ChannelId: originalPost.ChannelId,
|
||||
AcknowledgedAt: model.GetMillis(),
|
||||
}
|
||||
_, appErr = th.App.SaveAcknowledgementForPostWithModel(th.Context, ackForSync)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Clear previous synced posts and trigger sync
|
||||
syncedPosts = syncedPosts[:0]
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Wait for acknowledgement sync
|
||||
require.Eventually(t, func() bool {
|
||||
for _, post := range syncedPosts {
|
||||
if post.Id == postIdToSync &&
|
||||
post.Metadata != nil &&
|
||||
post.Metadata.Acknowledgements != nil &&
|
||||
len(post.Metadata.Acknowledgements) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond, "Should sync acknowledgements back")
|
||||
|
||||
// Verify acknowledgement was synced
|
||||
var syncedPostWithAcks *model.Post
|
||||
for _, post := range syncedPosts {
|
||||
if post.Id == postIdToSync && post.Metadata != nil && post.Metadata.Acknowledgements != nil {
|
||||
syncedPostWithAcks = post
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, syncedPostWithAcks, "Should find synced post with acknowledgements")
|
||||
require.NotNil(t, syncedPostWithAcks.Metadata.Acknowledgements, "Acknowledgements should exist")
|
||||
require.Len(t, syncedPostWithAcks.Metadata.Acknowledgements, 1, "Should have exactly 1 acknowledgement")
|
||||
|
||||
ack := syncedPostWithAcks.Metadata.Acknowledgements[0]
|
||||
assert.Equal(t, th.BasicUser2.Id, ack.UserId, "Acknowledgement should be from BasicUser2")
|
||||
assert.Equal(t, originalPost.Id, ack.PostId, "Acknowledgement should be for the original post")
|
||||
assert.Greater(t, ack.AcknowledgedAt, int64(0), "Acknowledgement should have a timestamp")
|
||||
})
|
||||
|
||||
t.Run("Persistent Notifications Self-Referential Sync", func(t *testing.T) {
|
||||
EnsureCleanState(t, th, th.App.Srv().Store())
|
||||
var syncedPosts []*model.Post
|
||||
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKResponse(w)
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
testChannel, selfCluster := createSharedChannelSetup(t, th, service, testServer)
|
||||
|
||||
syncHandler := NewSelfReferentialSyncHandler(t, service, selfCluster)
|
||||
syncHandler.OnPostSync = func(post *model.Post) {
|
||||
syncedPosts = append(syncedPosts, post)
|
||||
}
|
||||
|
||||
testServer.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncHandler.HandleRequest(w, r)
|
||||
})
|
||||
|
||||
// Create post with persistent notifications enabled
|
||||
_, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: testChannel.Id,
|
||||
Message: "Test post with persistent notifications @" + th.BasicUser2.Username,
|
||||
Metadata: &model.PostMetadata{
|
||||
Priority: &model.PostPriority{
|
||||
Priority: model.NewPointer(model.PostPriorityUrgent),
|
||||
RequestedAck: model.NewPointer(true),
|
||||
PersistentNotifications: model.NewPointer(true),
|
||||
},
|
||||
},
|
||||
}, testChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Trigger sync
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Wait for sync completion
|
||||
require.Eventually(t, func() bool {
|
||||
return len(syncedPosts) >= 2
|
||||
}, 5*time.Second, 100*time.Millisecond, "Should receive synced posts via self-referential handler")
|
||||
|
||||
// Verify persistent notifications setting is preserved
|
||||
syncedPost := syncedPosts[len(syncedPosts)-1]
|
||||
require.NotNil(t, syncedPost.Metadata, "Post metadata should be preserved")
|
||||
require.NotNil(t, syncedPost.Metadata.Priority, "Priority metadata should be preserved")
|
||||
assert.Equal(t, model.PostPriorityUrgent, *syncedPost.Metadata.Priority.Priority, "Priority should be preserved")
|
||||
assert.True(t, *syncedPost.Metadata.Priority.RequestedAck, "RequestedAck should be preserved")
|
||||
assert.True(t, *syncedPost.Metadata.Priority.PersistentNotifications, "PersistentNotifications should be preserved")
|
||||
})
|
||||
|
||||
t.Run("Cross-Cluster Acknowledgement End-to-End Flow", func(t *testing.T) {
|
||||
EnsureCleanState(t, th, th.App.Srv().Store())
|
||||
var syncedPostsServerA []*model.Post
|
||||
var syncedPostsServerB []*model.Post
|
||||
var postIdToTrack string
|
||||
|
||||
// Create test HTTP servers for both "clusters"
|
||||
testServerA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKResponse(w)
|
||||
}))
|
||||
defer testServerA.Close()
|
||||
|
||||
testServerB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKResponse(w)
|
||||
}))
|
||||
defer testServerB.Close()
|
||||
|
||||
// Create remote clusters for both "servers"
|
||||
clusterA := &model.RemoteCluster{
|
||||
RemoteId: model.NewId(),
|
||||
Name: "cluster-a-ack-flow",
|
||||
SiteURL: testServerA.URL,
|
||||
CreateAt: model.GetMillis(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
Token: model.NewId(),
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteToken: model.NewId(),
|
||||
}
|
||||
var err error
|
||||
clusterA, err = th.App.Srv().Store().RemoteCluster().Save(clusterA)
|
||||
require.NoError(t, err)
|
||||
|
||||
clusterB := &model.RemoteCluster{
|
||||
RemoteId: model.NewId(),
|
||||
Name: "cluster-b-ack-flow",
|
||||
SiteURL: testServerB.URL,
|
||||
CreateAt: model.GetMillis(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
Token: model.NewId(),
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteToken: model.NewId(),
|
||||
}
|
||||
clusterB, err = th.App.Srv().Store().RemoteCluster().Save(clusterB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test channel and add local user
|
||||
testChannel := th.CreatePublicChannel()
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, testChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Create remote user from Cluster B
|
||||
remoteUserFromClusterB := &model.User{
|
||||
Email: "remote-user-b@example.com",
|
||||
Username: "remoteuserb" + model.NewId()[:4],
|
||||
Password: "password123",
|
||||
EmailVerified: true,
|
||||
RemoteId: &clusterB.RemoteId,
|
||||
}
|
||||
remoteUserFromClusterB, appErr = th.App.CreateUser(th.Context, remoteUserFromClusterB)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Add remote user to team and channel
|
||||
_, _, appErr = th.App.AddUserToTeam(th.Context, testChannel.TeamId, remoteUserFromClusterB.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, remoteUserFromClusterB, testChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Create shared channel
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: testChannel.Id,
|
||||
TeamId: testChannel.TeamId,
|
||||
Home: true,
|
||||
ShareName: "test_cross_cluster_ack",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteId: "",
|
||||
}
|
||||
sc, err = th.App.ShareChannel(th.Context, sc)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create shared channel remotes for both clusters
|
||||
scrA := &model.SharedChannelRemote{
|
||||
Id: model.NewId(),
|
||||
ChannelId: sc.ChannelId,
|
||||
CreatorId: sc.CreatorId,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
RemoteId: clusterA.RemoteId,
|
||||
}
|
||||
_, err = th.App.SaveSharedChannelRemote(scrA)
|
||||
require.NoError(t, err)
|
||||
|
||||
scrB := &model.SharedChannelRemote{
|
||||
Id: model.NewId(),
|
||||
ChannelId: sc.ChannelId,
|
||||
CreatorId: sc.CreatorId,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
RemoteId: clusterB.RemoteId,
|
||||
}
|
||||
_, err = th.App.SaveSharedChannelRemote(scrB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initialize sync handlers for both clusters
|
||||
syncHandlerA := NewSelfReferentialSyncHandler(t, service, clusterA)
|
||||
syncHandlerA.OnPostSync = func(post *model.Post) {
|
||||
t.Logf("Cluster A received sync: ID=%s, Message=%s, HasAcks=%v",
|
||||
post.Id, post.Message,
|
||||
post.Metadata != nil && post.Metadata.Acknowledgements != nil)
|
||||
if post.Metadata != nil && post.Metadata.Acknowledgements != nil {
|
||||
t.Logf(" Cluster A sees %d acknowledgements", len(post.Metadata.Acknowledgements))
|
||||
}
|
||||
syncedPostsServerA = append(syncedPostsServerA, post)
|
||||
}
|
||||
|
||||
syncHandlerB := NewSelfReferentialSyncHandler(t, service, clusterB)
|
||||
syncHandlerB.OnPostSync = func(post *model.Post) {
|
||||
t.Logf("Cluster B received sync: ID=%s, Message=%s, RequestedAck=%v",
|
||||
post.Id, post.Message,
|
||||
post.Metadata != nil && post.Metadata.Priority != nil && post.Metadata.Priority.RequestedAck != nil && *post.Metadata.Priority.RequestedAck)
|
||||
syncedPostsServerB = append(syncedPostsServerB, post)
|
||||
}
|
||||
|
||||
// Update test servers to use sync handlers
|
||||
testServerA.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncHandlerA.HandleRequest(w, r)
|
||||
})
|
||||
|
||||
testServerB.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncHandlerB.HandleRequest(w, r)
|
||||
})
|
||||
|
||||
// STEP 1: Server A creates a post with acknowledgement request
|
||||
t.Log("=== STEP 1: Server A creates post with ack request ===")
|
||||
originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: testChannel.Id,
|
||||
Message: "Cross-cluster ack test - please acknowledge",
|
||||
Metadata: &model.PostMetadata{
|
||||
Priority: &model.PostPriority{
|
||||
Priority: model.NewPointer(model.PostPriorityUrgent),
|
||||
RequestedAck: model.NewPointer(true),
|
||||
PersistentNotifications: model.NewPointer(false),
|
||||
},
|
||||
},
|
||||
}, testChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
postIdToTrack = originalPost.Id
|
||||
|
||||
// Verify initial state - no acknowledgements
|
||||
acks, appErr := th.App.GetAcknowledgementsForPost(originalPost.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Empty(t, acks, "Should have no acknowledgements initially")
|
||||
|
||||
// STEP 2: Post syncs from Server A to Server B
|
||||
t.Log("=== STEP 2: Post syncs from Server A to Server B ===")
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Wait for Server B to receive the post
|
||||
var syncedPostIdOnServerB string
|
||||
require.Eventually(t, func() bool {
|
||||
for _, post := range syncedPostsServerB {
|
||||
if post.Message == originalPost.Message && post.Metadata != nil && post.Metadata.Priority != nil &&
|
||||
post.Metadata.Priority.RequestedAck != nil && *post.Metadata.Priority.RequestedAck {
|
||||
syncedPostIdOnServerB = post.Id
|
||||
t.Logf("Server B received post %s with ack request (original was %s)", post.Id, postIdToTrack)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond, "Server B should receive post with ack request")
|
||||
|
||||
// STEP 3: User on Server B acknowledges the post
|
||||
t.Log("=== STEP 3: User on Server B acknowledges the post ===")
|
||||
ackFromServerB := &model.PostAcknowledgement{
|
||||
PostId: syncedPostIdOnServerB,
|
||||
UserId: remoteUserFromClusterB.Id,
|
||||
ChannelId: testChannel.Id,
|
||||
AcknowledgedAt: model.GetMillis(),
|
||||
}
|
||||
_, appErr = th.App.SaveAcknowledgementForPostWithModel(th.Context, ackFromServerB)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify acknowledgement was saved locally
|
||||
acksAfterSave, appErr := th.App.GetAcknowledgementsForPost(syncedPostIdOnServerB)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, acksAfterSave, 1, "Should have exactly 1 acknowledgement after user B acks")
|
||||
require.Equal(t, remoteUserFromClusterB.Id, acksAfterSave[0].UserId)
|
||||
|
||||
// STEP 4: Acknowledgement syncs back from Server B to Server A
|
||||
t.Log("=== STEP 4: Acknowledgement syncs back from Server B to Server A ===")
|
||||
// Clear previous sync data to focus on acknowledgement sync
|
||||
syncedPostsServerA = syncedPostsServerA[:0]
|
||||
syncedPostsServerB = syncedPostsServerB[:0]
|
||||
|
||||
// Trigger sync to send acknowledgement back to Server A
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Wait for Server A to receive the acknowledgement sync
|
||||
require.Eventually(t, func() bool {
|
||||
for _, post := range syncedPostsServerA {
|
||||
if post.Id == postIdToTrack && post.Metadata != nil && post.Metadata.Acknowledgements != nil {
|
||||
t.Logf("Server A received post %s with %d acknowledgements", post.Id, len(post.Metadata.Acknowledgements))
|
||||
return len(post.Metadata.Acknowledgements) > 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond, "Server A should receive acknowledgement sync")
|
||||
|
||||
// STEP 5: Verify the complete acknowledgement flow
|
||||
t.Log("=== STEP 5: Verify complete acknowledgement flow ===")
|
||||
var serverAPostWithAcks *model.Post
|
||||
for _, post := range syncedPostsServerA {
|
||||
if post.Id == postIdToTrack && post.Metadata != nil && post.Metadata.Acknowledgements != nil {
|
||||
serverAPostWithAcks = post
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, serverAPostWithAcks, "Server A should receive post with acknowledgements")
|
||||
require.NotNil(t, serverAPostWithAcks.Metadata.Acknowledgements, "Acknowledgements should exist")
|
||||
require.Len(t, serverAPostWithAcks.Metadata.Acknowledgements, 1, "Should have exactly 1 acknowledgement")
|
||||
|
||||
// Verify acknowledgement details
|
||||
ack := serverAPostWithAcks.Metadata.Acknowledgements[0]
|
||||
assert.Equal(t, remoteUserFromClusterB.Id, ack.UserId, "Acknowledgement should be from remote user")
|
||||
assert.Equal(t, postIdToTrack, ack.PostId, "Acknowledgement should be for the correct post")
|
||||
assert.Greater(t, ack.AcknowledgedAt, int64(0), "Acknowledgement should have a timestamp")
|
||||
|
||||
// Verify priority metadata is preserved
|
||||
require.NotNil(t, serverAPostWithAcks.Metadata.Priority, "Priority metadata should be preserved")
|
||||
assert.Equal(t, model.PostPriorityUrgent, *serverAPostWithAcks.Metadata.Priority.Priority, "Priority should be preserved")
|
||||
assert.True(t, *serverAPostWithAcks.Metadata.Priority.RequestedAck, "RequestedAck should be preserved")
|
||||
|
||||
// STEP 6: Test echo prevention - verify no duplicate acknowledgements
|
||||
t.Log("=== STEP 6: Test echo prevention ===")
|
||||
syncedPostsServerA = syncedPostsServerA[:0]
|
||||
|
||||
// Trigger another sync to ensure no duplicates are created
|
||||
service.NotifyChannelChanged(testChannel.Id)
|
||||
|
||||
// Verify acknowledgement count remains 1 (no duplicates)
|
||||
require.Eventually(t, func() bool {
|
||||
for _, post := range syncedPostsServerA {
|
||||
if post.Id == postIdToTrack && post.Metadata != nil && post.Metadata.Acknowledgements != nil {
|
||||
return len(post.Metadata.Acknowledgements) == 1
|
||||
}
|
||||
}
|
||||
return len(syncedPostsServerA) > 0
|
||||
}, 3*time.Second, 100*time.Millisecond, "Should maintain single acknowledgement after resync")
|
||||
|
||||
t.Logf("✅ Cross-cluster acknowledgement flow completed successfully:")
|
||||
t.Logf(" 1. Server A created post with ack request: %s", postIdToTrack)
|
||||
t.Logf(" 2. Post synced to Server B with priority metadata intact")
|
||||
t.Logf(" 3. User on Server B acknowledged the post: %s", ack.UserId)
|
||||
t.Logf(" 4. Acknowledgement synced back to Server A")
|
||||
t.Logf(" 5. Server A shows acknowledgement in post metadata")
|
||||
t.Logf(" 6. Echo prevention verified - no duplicates created")
|
||||
})
|
||||
}
|
||||
245
server/channels/api4/shared_channel_test_utils.go
Обычный файл
245
server/channels/api4/shared_channel_test_utils.go
Обычный файл
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// writeOKResponse writes a standard OK JSON response in the format expected by remotecluster
|
||||
func writeOKResponse(w http.ResponseWriter) {
|
||||
response := &remotecluster.Response{
|
||||
Status: "OK",
|
||||
Err: "",
|
||||
}
|
||||
|
||||
// Set empty sync response as payload
|
||||
syncResp := &model.SyncResponse{}
|
||||
_ = response.SetPayload(syncResp)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
respBytes, _ := json.Marshal(response)
|
||||
_, _ = w.Write(respBytes)
|
||||
}
|
||||
|
||||
// SelfReferentialSyncHandler handles incoming sync messages for self-referential tests.
|
||||
type SelfReferentialSyncHandler struct {
|
||||
t *testing.T
|
||||
service *sharedchannel.Service
|
||||
selfCluster *model.RemoteCluster
|
||||
syncMessageCount *int32
|
||||
|
||||
// Callbacks for capturing sync data
|
||||
OnPostSync func(post *model.Post)
|
||||
OnAcknowledgementSync func(ack *model.PostAcknowledgement)
|
||||
}
|
||||
|
||||
// NewSelfReferentialSyncHandler creates a new handler for processing sync messages in tests
|
||||
func NewSelfReferentialSyncHandler(t *testing.T, service *sharedchannel.Service, selfCluster *model.RemoteCluster) *SelfReferentialSyncHandler {
|
||||
count := int32(0)
|
||||
return &SelfReferentialSyncHandler{
|
||||
t: t,
|
||||
service: service,
|
||||
selfCluster: selfCluster,
|
||||
syncMessageCount: &count,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRequest processes incoming HTTP requests for the test server.
|
||||
func (h *SelfReferentialSyncHandler) HandleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v4/remotecluster/msg":
|
||||
atomic.AddInt32(h.syncMessageCount, 1)
|
||||
|
||||
// Read and process the sync message
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
// The message is wrapped in a RemoteClusterFrame
|
||||
var frame model.RemoteClusterFrame
|
||||
err := json.Unmarshal(body, &frame)
|
||||
if err == nil {
|
||||
// Process the message to update cursor
|
||||
response := &remotecluster.Response{}
|
||||
processErr := h.service.OnReceiveSyncMessageForTesting(frame.Msg, h.selfCluster, response)
|
||||
if processErr != nil {
|
||||
response.Status = "ERROR"
|
||||
response.Err = processErr.Error()
|
||||
h.t.Logf("Sync processing error: %v", processErr)
|
||||
} else {
|
||||
response.Status = "OK"
|
||||
response.Err = ""
|
||||
|
||||
var syncMsg model.SyncMsg
|
||||
if unmarshalErr := json.Unmarshal(frame.Msg.Payload, &syncMsg); unmarshalErr == nil {
|
||||
// Handle posts - call callback for verification
|
||||
if len(syncMsg.Posts) > 0 && h.OnPostSync != nil {
|
||||
for _, post := range syncMsg.Posts {
|
||||
h.OnPostSync(post)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle acknowledgements - call callback for verification
|
||||
if len(syncMsg.Acknowledgements) > 0 && h.OnAcknowledgementSync != nil {
|
||||
for _, ack := range syncMsg.Acknowledgements {
|
||||
h.OnAcknowledgementSync(ack)
|
||||
}
|
||||
}
|
||||
|
||||
// Create success response
|
||||
syncResp := &model.SyncResponse{
|
||||
UsersSyncd: make([]string, 0),
|
||||
}
|
||||
_ = response.SetPayload(syncResp)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
respBytes, _ := json.Marshal(response)
|
||||
_, _ = w.Write(respBytes)
|
||||
return
|
||||
}
|
||||
|
||||
writeOKResponse(w)
|
||||
|
||||
case "/api/v4/remotecluster/ping":
|
||||
writeOKResponse(w)
|
||||
|
||||
case "/api/v4/remotecluster/confirm_invite":
|
||||
writeOKResponse(w)
|
||||
|
||||
default:
|
||||
writeOKResponse(w)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSyncMessageCount returns the current count of sync messages received
|
||||
func (h *SelfReferentialSyncHandler) GetSyncMessageCount() int32 {
|
||||
return atomic.LoadInt32(h.syncMessageCount)
|
||||
}
|
||||
|
||||
// ensureCleanState ensures a clean test state by removing all shared channels and remote clusters.
|
||||
// This helps prevent state pollution between tests.
|
||||
func EnsureCleanState(t *testing.T, th *TestHelper, ss store.Store) {
|
||||
t.Helper()
|
||||
|
||||
// First, wait for any pending async tasks to complete, then shutdown services
|
||||
scsInterface := th.App.Srv().GetSharedChannelSyncService()
|
||||
if scsInterface != nil && scsInterface.Active() {
|
||||
// Cast to concrete type to access testing methods
|
||||
if service, ok := scsInterface.(*sharedchannel.Service); ok {
|
||||
// Wait for any pending tasks from previous tests to complete
|
||||
require.Eventually(t, func() bool {
|
||||
return !service.HasPendingTasksForTesting()
|
||||
}, 10*time.Second, 100*time.Millisecond, "All pending sync tasks should complete before cleanup")
|
||||
}
|
||||
|
||||
// Shutdown the shared channel service to stop any async operations
|
||||
_ = scsInterface.Shutdown()
|
||||
|
||||
// Wait for shutdown to complete with more time
|
||||
require.Eventually(t, func() bool {
|
||||
return !scsInterface.Active()
|
||||
}, 5*time.Second, 100*time.Millisecond, "Shared channel service should be inactive after shutdown")
|
||||
}
|
||||
|
||||
// Clear all shared channels and remotes from previous tests
|
||||
allSharedChannels, _ := ss.SharedChannel().GetAll(0, 1000, model.SharedChannelFilterOpts{})
|
||||
for _, sc := range allSharedChannels {
|
||||
// Delete all remotes for this channel
|
||||
remotes, _ := ss.SharedChannel().GetRemotes(0, 100, model.SharedChannelRemoteFilterOpts{ChannelId: sc.ChannelId})
|
||||
for _, remote := range remotes {
|
||||
_, _ = ss.SharedChannel().DeleteRemote(remote.Id)
|
||||
}
|
||||
// Delete the shared channel
|
||||
_, _ = ss.SharedChannel().Delete(sc.ChannelId)
|
||||
}
|
||||
|
||||
// Delete all remote clusters
|
||||
allRemoteClusters, _ := ss.RemoteCluster().GetAll(0, 1000, model.RemoteClusterQueryFilter{})
|
||||
for _, rc := range allRemoteClusters {
|
||||
_, _ = ss.RemoteCluster().Delete(rc.RemoteId)
|
||||
}
|
||||
|
||||
// Clear all acknowledgements from previous tests by setting AcknowledgedAt to 0
|
||||
// This uses raw SQL to ensure all acknowledgements are cleared regardless of their state
|
||||
if sqlStore, ok := ss.(*sqlstore.SqlStore); ok {
|
||||
_, _ = sqlStore.GetMaster().Exec("UPDATE PostAcknowledgements SET AcknowledgedAt = 0")
|
||||
}
|
||||
|
||||
// Remove all channel members from test channels (except the basic team/channel setup)
|
||||
channels, _ := ss.Channel().GetAll(th.BasicTeam.Id)
|
||||
for _, channel := range channels {
|
||||
// Skip direct message and group channels, and skip the default channels
|
||||
if channel.Type != model.ChannelTypeDirect && channel.Type != model.ChannelTypeGroup &&
|
||||
channel.Id != th.BasicChannel.Id {
|
||||
members, _ := ss.Channel().GetMembers(model.ChannelMembersGetOptions{
|
||||
ChannelID: channel.Id,
|
||||
})
|
||||
for _, member := range members {
|
||||
_ = ss.Channel().RemoveMember(th.Context, channel.Id, member.UserId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all active users and deactivate non-basic ones
|
||||
options := &model.UserGetOptions{
|
||||
Page: 0,
|
||||
PerPage: 200,
|
||||
Active: true,
|
||||
}
|
||||
users, _ := ss.User().GetAllProfiles(options)
|
||||
for _, user := range users {
|
||||
// Keep only the basic test users active
|
||||
if user.Id != th.BasicUser.Id && user.Id != th.BasicUser2.Id &&
|
||||
user.Id != th.SystemAdminUser.Id {
|
||||
// Deactivate the user (soft delete)
|
||||
user.DeleteAt = model.GetMillis()
|
||||
_, _ = ss.User().Update(th.Context, user, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cleanup is complete
|
||||
require.Eventually(t, func() bool {
|
||||
sharedChannels, _ := ss.SharedChannel().GetAll(0, 1000, model.SharedChannelFilterOpts{})
|
||||
remoteClusters, _ := ss.RemoteCluster().GetAll(0, 1000, model.RemoteClusterQueryFilter{})
|
||||
return len(sharedChannels) == 0 && len(remoteClusters) == 0
|
||||
}, 2*time.Second, 100*time.Millisecond, "Failed to clean up shared channels and remote clusters")
|
||||
|
||||
// Restart services and ensure they are running and ready
|
||||
if scsInterface != nil {
|
||||
// Restart the shared channel service
|
||||
_ = scsInterface.Start()
|
||||
|
||||
if scs, ok := scsInterface.(*sharedchannel.Service); ok {
|
||||
require.Eventually(t, func() bool {
|
||||
return scs.Active()
|
||||
}, 5*time.Second, 100*time.Millisecond, "Shared channel service should be active after restart")
|
||||
}
|
||||
}
|
||||
|
||||
rcService := th.App.Srv().GetRemoteClusterService()
|
||||
if rcService != nil {
|
||||
if rc, ok := rcService.(*remotecluster.Service); ok {
|
||||
rc.SetActive(true)
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
return rcService.Active()
|
||||
}, 5*time.Second, 100*time.Millisecond, "Remote cluster service should be active")
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user