Full Mattermost server source with integrated Community Enterprise features. Includes vendor directory for offline/air-gapped builds. Structure: - enterprise-impl/: Enterprise feature implementations - enterprise-community/: Init files that register implementations - enterprise/: Bridge imports (community_imports.go) - vendor/: All dependencies for offline builds Build (online): go build ./cmd/mattermost Build (offline/air-gapped): go build -mod=vendor ./cmd/mattermost 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
410 lines
13 KiB
Go
410 lines
13 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestProcessScheduledPosts(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
t.Run("base case - happy path", func(t *testing.T) {
|
|
th := Setup(t).InitBasic()
|
|
defer th.TearDown()
|
|
|
|
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
|
|
|
|
scheduledAt := model.GetMillis() + 1000
|
|
scheduledPost1 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is a scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1)
|
|
assert.NoError(t, err)
|
|
|
|
scheduledPost2 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is second scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2)
|
|
assert.NoError(t, err)
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
th.App.ProcessScheduledPosts(th.Context)
|
|
|
|
scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, scheduledPosts, 0)
|
|
})
|
|
|
|
t.Run("sets error code for archived channel", func(t *testing.T) {
|
|
th := Setup(t).InitBasic()
|
|
defer th.TearDown()
|
|
|
|
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
|
|
|
|
appErr := th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id)
|
|
assert.Nil(t, appErr)
|
|
|
|
scheduledAt := model.GetMillis() - (5 * 60 * 60 * 1000)
|
|
scheduledPost1 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is a scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1)
|
|
assert.NoError(t, err)
|
|
|
|
scheduledPost2 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is second scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2)
|
|
assert.NoError(t, err)
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
th.App.ProcessScheduledPosts(th.Context)
|
|
|
|
// since the channel ID we set in the above created scheduled posts is of a
|
|
// non-existing channel, the job should have set the appropriate error code for them in the database
|
|
scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, scheduledPosts, 2)
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeChannelArchived, scheduledPosts[0].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[0].ProcessedAt, int64(0))
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeChannelArchived, scheduledPosts[1].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[1].ProcessedAt, int64(0))
|
|
})
|
|
|
|
t.Run("sets error code for archived user", func(t *testing.T) {
|
|
th := Setup(t).InitBasic()
|
|
defer th.TearDown()
|
|
|
|
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
|
|
|
|
scheduledAt := model.GetMillis() + 1000
|
|
scheduledPost1 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is a scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1)
|
|
assert.NoError(t, err)
|
|
|
|
scheduledPost2 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is second scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2)
|
|
assert.NoError(t, err)
|
|
|
|
_, appErr := th.App.UpdateActive(th.Context, th.BasicUser, false)
|
|
assert.Nil(t, appErr)
|
|
|
|
defer func() {
|
|
_, _ = th.App.UpdateActive(th.Context, th.BasicUser, true)
|
|
}()
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
th.App.ProcessScheduledPosts(th.Context)
|
|
|
|
scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, scheduledPosts, 2)
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeUserDeleted, scheduledPosts[0].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[0].ProcessedAt, int64(0))
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeUserDeleted, scheduledPosts[1].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[1].ProcessedAt, int64(0))
|
|
})
|
|
|
|
t.Run("sets error code when user is not a channel member", func(t *testing.T) {
|
|
th := Setup(t).InitBasic()
|
|
defer th.TearDown()
|
|
|
|
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
|
|
|
|
scheduledAt := model.GetMillis() + 1000
|
|
scheduledPost1 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is a scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1)
|
|
assert.NoError(t, err)
|
|
|
|
scheduledPost2 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is second scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2)
|
|
assert.NoError(t, err)
|
|
|
|
appErr := th.App.LeaveChannel(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
|
|
assert.Nil(t, appErr)
|
|
|
|
defer func() {
|
|
_ = th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser.Id)
|
|
}()
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
th.App.ProcessScheduledPosts(th.Context)
|
|
|
|
scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, scheduledPosts, 2)
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeNoChannelPermission, scheduledPosts[0].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[0].ProcessedAt, int64(0))
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeNoChannelPermission, scheduledPosts[1].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[1].ProcessedAt, int64(0))
|
|
})
|
|
|
|
t.Run("sets error code when user is not a team member", func(t *testing.T) {
|
|
th := Setup(t).InitBasic()
|
|
defer th.TearDown()
|
|
|
|
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
|
|
|
|
scheduledAt := model.GetMillis() + 1000
|
|
scheduledPost1 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is a scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1)
|
|
assert.NoError(t, err)
|
|
|
|
scheduledPost2 := &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "this is second scheduled post",
|
|
},
|
|
ScheduledAt: scheduledAt,
|
|
}
|
|
_, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2)
|
|
assert.NoError(t, err)
|
|
|
|
appErr := th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.BasicUser.Id, th.BasicUser.Id)
|
|
assert.Nil(t, appErr)
|
|
|
|
defer func() {
|
|
_, _, _ = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.BasicUser.Id, th.BasicUser.Id)
|
|
}()
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
th.App.ProcessScheduledPosts(th.Context)
|
|
|
|
scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, scheduledPosts, 2)
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeNoChannelPermission, scheduledPosts[0].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[0].ProcessedAt, int64(0))
|
|
|
|
assert.Equal(t, model.ScheduledPostErrorCodeNoChannelPermission, scheduledPosts[1].ErrorCode)
|
|
assert.Greater(t, scheduledPosts[1].ProcessedAt, int64(0))
|
|
})
|
|
}
|
|
|
|
func TestHandleFailedScheduledPosts(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic()
|
|
defer th.TearDown()
|
|
|
|
t.Run("should handle failed scheduled posts correctly and notify users about failure via system-bot", func(t *testing.T) {
|
|
rctx := th.Context
|
|
var err error
|
|
var appErr *model.AppError
|
|
var systemBot *model.Bot
|
|
|
|
systemBot, appErr = th.App.GetSystemBot(rctx)
|
|
assert.True(t, appErr == nil)
|
|
assert.NotNil(t, systemBot)
|
|
|
|
user1 := th.BasicUser
|
|
user2 := th.BasicUser2
|
|
|
|
channel1 := th.BasicChannel
|
|
channel2 := th.CreateChannel(th.Context, th.BasicTeam)
|
|
|
|
// Create failed scheduled posts: 1 for user1 and 2 for user2
|
|
failedScheduledPosts := []*model.ScheduledPost{
|
|
{
|
|
Id: model.NewId(),
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: user1.Id,
|
|
ChannelId: channel1.Id,
|
|
Message: "Failed scheduled post for user 1",
|
|
},
|
|
ErrorCode: model.ScheduledPostErrorUnknownError,
|
|
},
|
|
{
|
|
Id: model.NewId(),
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: user2.Id,
|
|
ChannelId: channel1.Id,
|
|
Message: "Failed scheduled post 1 for user 2",
|
|
},
|
|
ErrorCode: model.ScheduledPostErrorCodeNoChannelPermission,
|
|
},
|
|
{
|
|
Id: model.NewId(),
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: user2.Id,
|
|
ChannelId: channel2.Id,
|
|
Message: "Failed scheduled post 2 for user 2",
|
|
},
|
|
ErrorCode: model.ScheduledPostErrorNoChannelMember,
|
|
},
|
|
}
|
|
|
|
// Save the failed scheduled posts in the store
|
|
for _, sp := range failedScheduledPosts {
|
|
_, err = th.Server.Store().ScheduledPost().CreateScheduledPost(sp)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
// Mock WebSocket channels for both of the two users
|
|
messagesUser1, closeWSUser1 := connectFakeWebSocket(t, th, user1.Id, "", []model.WebsocketEventType{model.WebsocketScheduledPostUpdated})
|
|
defer closeWSUser1()
|
|
|
|
messagesUser2, closeWSUser2 := connectFakeWebSocket(t, th, user2.Id, "", []model.WebsocketEventType{model.WebsocketScheduledPostUpdated})
|
|
defer closeWSUser2()
|
|
|
|
th.App.handleFailedScheduledPosts(rctx, failedScheduledPosts)
|
|
|
|
// Validate that the WebSocket events for both users are sent and received correctly
|
|
for i := range failedScheduledPosts {
|
|
var received *model.WebSocketEvent
|
|
select {
|
|
case received = <-messagesUser1:
|
|
if received.GetBroadcast().UserId == user1.Id {
|
|
assert.Equal(t, model.WebsocketScheduledPostUpdated, received.EventType())
|
|
}
|
|
case received = <-messagesUser2:
|
|
if received.GetBroadcast().UserId == user2.Id {
|
|
assert.Equal(t, model.WebsocketScheduledPostUpdated, received.EventType())
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Errorf("Timeout while waiting for a WebSocket event for scheduled post %d", i+1)
|
|
}
|
|
}
|
|
|
|
// Helper function to check notifications for a specific user
|
|
checkUserNotification := func(user *model.User) {
|
|
// Wait time for notifications to be sent (adding 5 secs because it is run in a separate goroutine)
|
|
timeout := 5 * time.Second
|
|
begin := time.Now()
|
|
channel, appErr := th.App.GetOrCreateDirectChannel(rctx, user.Id, systemBot.UserId)
|
|
assert.True(t, appErr == nil)
|
|
|
|
var posts *model.PostList
|
|
// wait for the notification to be sent into the channel.
|
|
// idea is to get the channel and try to find posts, if not, wait 100ms and try again until timeout or there is posts length
|
|
for {
|
|
if time.Since(begin) > timeout {
|
|
break
|
|
}
|
|
posts, appErr = th.App.GetPosts(th.Context, channel.Id, 0, 10)
|
|
assert.True(t, appErr == nil)
|
|
if len(posts.Posts) > 0 {
|
|
break
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
assert.NotEmpty(t, posts.Posts, "Expected notification for user %s to have been sent", user.Id)
|
|
|
|
// Collect failed messages for users
|
|
var userFailedMessages []*model.ScheduledPost
|
|
for _, sp := range failedScheduledPosts {
|
|
if sp.UserId == user.Id {
|
|
userFailedMessages = append(userFailedMessages, sp)
|
|
}
|
|
}
|
|
|
|
T := i18n.GetUserTranslations(user.Locale)
|
|
messageHeader := T("app.scheduled_post.failed_messages", map[string]any{
|
|
"Count": len(userFailedMessages),
|
|
})
|
|
|
|
// Validate the actual content of the notification posted
|
|
found := false
|
|
for _, post := range posts.Posts {
|
|
if post.UserId == systemBot.UserId && strings.HasPrefix(post.Message, messageHeader) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.True(t, found, "\nNotification post not found for user %s with expected message prefix. \n Expected: %s \n", user.Id, messageHeader)
|
|
}
|
|
|
|
// Check notifications sent for failed messages for both users
|
|
checkUserNotification(user1)
|
|
checkUserNotification(user2)
|
|
})
|
|
}
|