Files
team/server/channels/app/notification_push_test.go
zeekay 8feb24ea1f One name for this fork, and it is ours
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.
2026-08-28 23:35:36 -07:00

2076 lines
76 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/i18n"
"github.com/hanzoteam/server/server/v8/channels/app/platform"
"github.com/hanzoteam/server/server/v8/channels/store/storetest/mocks"
"github.com/hanzoteam/server/server/v8/channels/testlib"
"github.com/hanzoteam/server/server/v8/config"
fmocks "github.com/hanzoteam/server/server/v8/platform/shared/filestore/mocks"
)
func TestDoesNotifyPropsAllowPushNotification(t *testing.T) {
mainHelper.Parallel(t)
tt := []struct {
name string
userNotifySetting string
channelNotifySetting string
withSystemPost bool
withSilentPost bool
wasMentioned bool
isMuted bool
expected model.NotificationReason
isGM bool
}{
{
name: "When post is a System Message and has no mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: "",
withSystemPost: true,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonSystemMessage,
isGM: false,
},
{
name: "When post is a System Message and has mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: "",
withSystemPost: true,
wasMentioned: true,
isMuted: false,
expected: model.NotificationReasonSystemMessage,
isGM: false,
},
{
name: "When post is a silent notification",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: "",
withSilentPost: true,
wasMentioned: true,
isMuted: false,
expected: model.NotificationReasonSilent,
isGM: false,
},
{
name: "When default is ALL, no channel props is set and has no mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: "",
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is ALL, no channel props is set and has mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: "",
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is MENTION, no channel props is set and has no mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: "",
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonNotMentioned,
isGM: false,
},
{
name: "When default is MENTION, no channel props is set and has mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: "",
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is NONE, no channel props is set and has no mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: "",
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is NONE, no channel props is set and has mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: "",
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is ALL, channel is DEFAULT and has no mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is ALL, channel is DEFAULT and has mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is MENTION, channel is DEFAULT and has no mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonNotMentioned,
isGM: false,
},
{
name: "When default is MENTION, channel is DEFAULT and has mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is NONE, channel is DEFAULT and has no mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is NONE, channel is DEFAULT and has mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is ALL, channel is ALL and has no mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyAll,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is ALL, channel is ALL and has mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyAll,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is MENTION, channel is ALL and has no mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyAll,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is MENTION, channel is ALL and has mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyAll,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is NONE, channel is ALL and has no mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyAll,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is NONE, channel is ALL and has mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyAll,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is ALL, channel is MENTION and has no mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonNotMentioned,
isGM: false,
},
{
name: "When default is ALL, channel is MENTION and has mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is MENTION, channel is MENTION and has no mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonNotMentioned,
isGM: false,
},
{
name: "When default is MENTION, channel is MENTION and has mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is NONE, channel is MENTION and has no mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonNotMentioned,
isGM: false,
},
{
name: "When default is NONE, channel is MENTION and has mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: false,
},
{
name: "When default is ALL, channel is NONE and has no mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyNone,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is ALL, channel is NONE and has mentions",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: model.ChannelNotifyNone,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is MENTION, channel is NONE and has no mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyNone,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is MENTION, channel is NONE and has mentions",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyNone,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is NONE, channel is NONE and has no mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyNone,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is NONE, channel is NONE and has mentions",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyNone,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: false,
},
{
name: "When default is ALL, and channel is MUTED",
userNotifySetting: model.UserNotifyAll,
channelNotifySetting: "",
withSystemPost: false,
wasMentioned: false,
isMuted: true,
expected: model.NotificationReasonChannelMuted,
isGM: false,
},
{
name: "For GM default for NONE is NONE",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonLevelSetToNone,
isGM: true,
},
{
name: "For GM, mentioned is only called if explicitly mentioned",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: true,
isMuted: false,
expected: "",
isGM: true,
},
{
name: "For GM default for MENTION is ALL",
userNotifySetting: model.UserNotifyMention,
channelNotifySetting: model.ChannelNotifyDefault,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: "",
isGM: true,
},
{
name: "For GM, mentioned is only called if explicitly mentioned",
userNotifySetting: model.UserNotifyNone,
channelNotifySetting: model.ChannelNotifyMention,
withSystemPost: false,
wasMentioned: false,
isMuted: false,
expected: model.NotificationReasonNotMentioned,
isGM: true,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
user := &model.User{Id: model.NewId(), Email: "[email protected]", NotifyProps: make(map[string]string)}
user.NotifyProps[model.PushNotifyProp] = tc.userNotifySetting
post := &model.Post{UserId: user.Id, ChannelId: model.NewId()}
if tc.withSystemPost {
post.Type = model.PostTypeJoinChannel
}
if tc.withSilentPost {
post.AddProp(model.PostPropsSilentNotification, true)
post.AddProp(model.PostPropsFromWebhook, "true")
}
channelNotifyProps := make(map[string]string)
if tc.channelNotifySetting != "" {
channelNotifyProps[model.PushNotifyProp] = tc.channelNotifySetting
}
if tc.isMuted {
channelNotifyProps[model.MarkUnreadNotifyProp] = model.ChannelMarkUnreadMention
}
assert.Equal(t, tc.expected, doesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, tc.wasMentioned, tc.isGM))
})
}
}
func TestDoesStatusAllowPushNotification(t *testing.T) {
mainHelper.Parallel(t)
userID := model.NewId()
channelID := model.NewId()
offline := &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
away := &model.Status{UserId: userID, Status: model.StatusAway, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
online := &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
dnd := &model.Status{UserId: userID, Status: model.StatusDnd, Manual: true, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
activeOnChannel := &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: channelID}
tt := []struct {
name string
userNotifySetting string
status *model.Status
channelID string
isCRT bool
expected model.NotificationReason
}{
{
name: "WHEN props is ONLINE and user is offline with channel",
userNotifySetting: model.StatusOnline,
status: offline,
channelID: channelID,
expected: "",
},
{
name: "WHEN props is ONLINE and user is offline without channel",
userNotifySetting: model.StatusOnline,
status: offline,
channelID: "",
expected: "",
},
{
name: "WHEN props is ONLINE and user is away with channel",
userNotifySetting: model.StatusOnline,
status: away,
channelID: channelID,
expected: "",
},
{
name: "WHEN props is ONLINE and user is away without channel",
userNotifySetting: model.StatusOnline,
status: away,
channelID: "",
expected: "",
},
{
name: "WHEN props is ONLINE and user is online with channel",
userNotifySetting: model.StatusOnline,
status: online,
channelID: channelID,
expected: "",
},
{
name: "WHEN props is ONLINE and user is online without channel",
userNotifySetting: model.StatusOnline,
status: online,
channelID: "",
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is ONLINE and user is online and active within the channel",
userNotifySetting: model.StatusOnline,
status: activeOnChannel,
channelID: channelID,
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is ONLINE and user is online and active within a thread in the channel",
userNotifySetting: model.StatusOnline,
status: activeOnChannel,
channelID: channelID,
expected: "",
isCRT: true,
},
{
name: "WHEN props is ONLINE and user is dnd with channel",
userNotifySetting: model.StatusOnline,
status: dnd,
channelID: channelID,
expected: model.NotificationReasonUserStatus,
},
{
name: "WHEN props is ONLINE and user is dnd without channel",
userNotifySetting: model.StatusOnline,
status: dnd,
channelID: "",
expected: model.NotificationReasonUserStatus,
},
{
name: "WHEN props is AWAY and user is offline with channel",
userNotifySetting: model.StatusAway,
status: offline,
channelID: channelID,
expected: "",
},
{
name: "WHEN props is AWAY and user is offline without channel",
userNotifySetting: model.StatusAway,
status: offline,
channelID: "",
expected: "",
},
{
name: "WHEN props is AWAY and user is away with channel",
userNotifySetting: model.StatusAway,
status: away,
channelID: channelID,
expected: "",
},
{
name: "WHEN props is AWAY and user is away without channel",
userNotifySetting: model.StatusAway,
status: away,
channelID: "",
expected: "",
},
{
name: "WHEN props is AWAY and user is online with channel",
userNotifySetting: model.StatusAway,
status: online,
channelID: channelID,
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is AWAY and user is online without channel",
userNotifySetting: model.StatusAway,
status: online,
channelID: "",
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is AWAY and user is dnd with channel",
userNotifySetting: model.StatusAway,
status: dnd,
channelID: channelID,
expected: model.NotificationReasonUserStatus,
},
{
name: "WHEN props is AWAY and user is dnd without channel",
userNotifySetting: model.StatusAway,
status: dnd,
channelID: "",
expected: model.NotificationReasonUserStatus,
},
{
name: "WHEN props is OFFLINE and user is offline with channel",
userNotifySetting: model.StatusOffline,
status: offline,
channelID: channelID,
expected: "",
},
{
name: "WHEN props is OFFLINE and user is offline without channel",
userNotifySetting: model.StatusOffline,
status: offline,
channelID: "",
expected: "",
},
{
name: "WHEN props is OFFLINE and user is away with channel",
userNotifySetting: model.StatusOffline,
status: away,
channelID: channelID,
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is OFFLINE and user is away without channel",
userNotifySetting: model.StatusOffline,
status: away,
channelID: "",
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is OFFLINE and user is online with channel",
userNotifySetting: model.StatusOffline,
status: online,
channelID: channelID,
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is OFFLINE and user is online without channel",
userNotifySetting: model.StatusOffline,
status: online,
channelID: "",
expected: model.NotificationReasonUserIsActive,
},
{
name: "WHEN props is OFFLINE and user is dnd with channel",
userNotifySetting: model.StatusOffline,
status: dnd,
channelID: channelID,
expected: model.NotificationReasonUserStatus,
},
{
name: "WHEN props is OFFLINE and user is dnd without channel",
userNotifySetting: model.StatusOffline,
status: dnd,
channelID: "",
expected: model.NotificationReasonUserStatus,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
userNotifyProps := make(map[string]string)
userNotifyProps["push_status"] = tc.userNotifySetting
assert.Equal(t, tc.expected, doesStatusAllowPushNotification(userNotifyProps, tc.status, tc.channelID, tc.isCRT))
})
}
}
func TestGetPushNotificationMessage(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
for name, tc := range map[string]struct {
Message string
explicitMention bool
channelWideMention bool
HasFiles bool
replyToThreadType string
Locale string
PushNotificationContents string
ChannelType model.ChannelType
ExpectedMessage string
}{
"full message, public channel, no mention": {
Message: "this is a message",
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user: this is a message",
},
"full message, public channel, mention": {
Message: "this is a message",
explicitMention: true,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user: this is a message",
},
"full message, public channel, channel wide mention": {
Message: "this is a message",
channelWideMention: true,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user: this is a message",
},
"full message, public channel, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user: this is a message",
},
"full message, public channel, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user: this is a message",
},
"full message, private channel, no mention": {
Message: "this is a message",
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user: this is a message",
},
"full message, private channel, mention": {
Message: "this is a message",
explicitMention: true,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user: this is a message",
},
"full message, private channel, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user: this is a message",
},
"full message, private channel, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user: this is a message",
},
"full message, group message channel, no mention": {
Message: "this is a message",
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user: this is a message",
},
"full message, group message channel, mention": {
Message: "this is a message",
explicitMention: true,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user: this is a message",
},
"full message, group message channel, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user: this is a message",
},
"full message, group message channel, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user: this is a message",
},
"full message, direct message channel, no mention": {
Message: "this is a message",
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "this is a message",
},
"full message, direct message channel, mention": {
Message: "this is a message",
explicitMention: true,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "this is a message",
},
"full message, direct message channel, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "this is a message",
},
"full message, direct message channel, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "this is a message",
},
"full message, direct message channel, commented on CRT enabled thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyCRT,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "user: this is a message",
},
"generic message with channel, public channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user posted a message.",
},
"generic message with channel, public channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user mentioned you.",
},
"generic message with channel, public channel, channel wide mention": {
Message: "this is a message",
channelWideMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user notified the channel.",
},
"generic message, public channel, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user commented on your post.",
},
"generic message, public channel, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user commented on a thread you participated in.",
},
"generic message with channel, private channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user posted a message.",
},
"generic message with channel, private channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user mentioned you.",
},
"generic message with channel, private channel, channel wide mention": {
Message: "this is a message",
channelWideMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user notified the channel.",
},
"generic message, public private, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user commented on your post.",
},
"generic message, public private, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user commented on a thread you participated in.",
},
"generic message with channel, group message channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user posted a message.",
},
"generic message with channel, group message channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user mentioned you.",
},
"generic message with channel, group message channel, channel wide mention": {
Message: "this is a message",
channelWideMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user notified the channel.",
},
"generic message, group message channel, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user commented on your post.",
},
"generic message, group message channel, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user commented on a thread you participated in.",
},
"generic message with channel, direct message channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "sent you a message.",
},
"generic message with channel, direct message channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "sent you a message.",
},
"generic message with channel, direct message channel, channel wide mention": {
Message: "this is a message",
channelWideMention: true,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "sent you a message.",
},
"generic message, direct message channel, commented on post": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyRoot,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "sent you a message.",
},
"generic message, direct message channel, commented on thread": {
Message: "this is a message",
replyToThreadType: model.CommentsNotifyAny,
PushNotificationContents: model.GenericNotification,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "sent you a message.",
},
"generic message without channel, public channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user posted a message.",
},
"generic message without channel, public channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user mentioned you.",
},
"generic message without channel, private channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user posted a message.",
},
"generic message without channel, private channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user mentioned you.",
},
"generic message without channel, group message channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user posted a message.",
},
"generic message without channel, group message channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user mentioned you.",
},
"generic message without channel, direct message channel, no mention": {
Message: "this is a message",
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "sent you a message.",
},
"generic message without channel, direct message channel, mention": {
Message: "this is a message",
explicitMention: true,
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "sent you a message.",
},
"only files, public channel": {
HasFiles: true,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user attached a file.",
},
"only files, private channel": {
HasFiles: true,
ChannelType: model.ChannelTypePrivate,
ExpectedMessage: "user attached a file.",
},
"only files, group message channel": {
HasFiles: true,
ChannelType: model.ChannelTypeGroup,
ExpectedMessage: "user attached a file.",
},
"only files, direct message channel": {
HasFiles: true,
ChannelType: model.ChannelTypeDirect,
ExpectedMessage: "attached a file.",
},
"only files without channel, public channel": {
HasFiles: true,
PushNotificationContents: model.GenericNoChannelNotification,
ChannelType: model.ChannelTypeOpen,
ExpectedMessage: "user attached a file.",
},
} {
t.Run(name, func(t *testing.T) {
locale := tc.Locale
if locale == "" {
locale = "en"
}
pushNotificationContents := tc.PushNotificationContents
if pushNotificationContents == "" {
pushNotificationContents = model.FullNotification
}
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationContents = pushNotificationContents
})
actualMessage := th.App.getPushNotificationMessage(
pushNotificationContents,
tc.Message,
tc.explicitMention,
tc.channelWideMention,
tc.HasFiles,
"user",
tc.ChannelType,
tc.replyToThreadType,
i18n.GetUserTranslations(locale),
)
assert.Equal(t, tc.ExpectedMessage, actualMessage)
})
}
}
func TestBuildPushNotificationMessageMentions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
team := th.CreateTeam(t)
sender := th.CreateUser(t)
receiver := th.CreateUser(t)
th.LinkUserToTeam(t, sender, team)
th.LinkUserToTeam(t, receiver, team)
channel1 := th.CreateChannel(t, team)
th.AddUserToChannel(t, sender, channel1)
th.AddUserToChannel(t, receiver, channel1)
channel2 := th.CreateChannel(t, team)
th.AddUserToChannel(t, sender, channel2)
th.AddUserToChannel(t, receiver, channel2)
// Create three mention posts and two non-mention posts
th.CreateMessagePost(t, channel1, "@channel Hello")
th.CreateMessagePost(t, channel1, "@all Hello")
th.CreateMessagePost(t, channel1, fmt.Sprintf("@%s Hello in channel 1", receiver.Username))
th.CreateMessagePost(t, channel2, fmt.Sprintf("@%s Hello in channel 2", receiver.Username))
th.CreatePost(t, channel1)
post := th.CreatePost(t, channel1)
for name, tc := range map[string]struct {
explicitMention bool
channelWideMention bool
replyToThreadType string
pushNotifyProps string
expectedBadge int
}{
"only mentions included for notify_props=mention": {
explicitMention: false,
channelWideMention: true,
replyToThreadType: "",
pushNotifyProps: "mention",
expectedBadge: 4,
},
"only mentions included for notify_props=all": {
explicitMention: false,
channelWideMention: true,
replyToThreadType: "",
pushNotifyProps: "all",
expectedBadge: 4,
},
} {
t.Run(name, func(t *testing.T) {
receiver.NotifyProps["push"] = tc.pushNotifyProps
msg, err := th.App.BuildPushNotificationMessage(th.Context, model.FullNotification, post, receiver, channel1, channel1.Name, sender.Username, tc.explicitMention, tc.channelWideMention, tc.replyToThreadType)
require.Nil(t, err)
assert.Equal(t, tc.expectedBadge, msg.Badge)
})
}
}
func TestSendPushNotifications(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, err := th.App.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
DeviceId: "test",
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, err)
t.Run("should return error if data is not valid or nil", func(t *testing.T) {
err := th.App.sendPushNotificationToAllSessions(th.Context, nil, th.BasicUser.Id, "")
require.NotNil(t, err)
assert.Equal(t, "api.push_notifications.message.parse.app_error", err.Id)
// Errors derived of using an empty object are handled internally through the notifications log
err = th.App.sendPushNotificationToAllSessions(th.Context, &model.PushNotification{}, th.BasicUser.Id, "")
require.Nil(t, err)
})
}
func TestSendPushNotificationsTransportRouting(t *testing.T) {
mainHelper.Parallel(t)
const (
standardToken = model.PushNotifyAppleReactNative + ":standardtoken"
voIPToken = model.PushNotifyAppleReactNative + ":voiptoken"
)
for _, tc := range []struct {
name string
deviceId string
voIPDeviceId string
sessionProps map[string]string
transport model.PushTransport
expectSent bool
expectedDeviceID string
expectedTransport model.PushTransport
}{
{
name: "VoIP transport with VoIP token registered uses VoIP token",
deviceId: standardToken,
voIPDeviceId: voIPToken,
transport: model.PushTransportVoIP,
expectSent: true,
expectedDeviceID: "voiptoken",
expectedTransport: model.PushTransportVoIP,
},
{
name: "VoIP transport without VoIP token downgrades to standard",
deviceId: standardToken,
voIPDeviceId: "",
transport: model.PushTransportVoIP,
expectSent: true,
expectedDeviceID: "standardtoken",
expectedTransport: model.PushTransportStandard,
},
{
name: "VoIP transport with invalidated VoIP token downgrades to standard",
deviceId: standardToken,
voIPDeviceId: voIPToken,
// The proxy previously reported "remove" for this VoIP token, so
// the session is marked. Subsequent VoIP-transport pushes must
// fall back to the standard alert path on the standard token.
sessionProps: map[string]string{model.SessionPropLastRemovedVoIPDeviceId: voIPToken},
transport: model.PushTransportVoIP,
expectSent: true,
expectedDeviceID: "standardtoken",
expectedTransport: model.PushTransportStandard,
},
{
name: "standard transport ignores VoIP token even when present",
deviceId: standardToken,
voIPDeviceId: voIPToken,
transport: model.PushTransportStandard,
expectSent: true,
expectedDeviceID: "standardtoken",
expectedTransport: model.PushTransportStandard,
},
{
// "Silence chat, keep ringing": iOS user turned off notifications,
// the standard alert token went 410 (last_removed_device_id set),
// but PushKit registration is independent and stays alive.
name: "VoIP transport with dead standard but live VoIP uses VoIP",
deviceId: standardToken,
voIPDeviceId: voIPToken,
sessionProps: map[string]string{model.SessionPropLastRemovedDeviceId: standardToken},
transport: model.PushTransportVoIP,
expectSent: true,
expectedDeviceID: "voiptoken",
expectedTransport: model.PushTransportVoIP,
},
{
// Companion to "silence chat, keep ringing": a chat push must NOT
// fall through to the VoIP token; the user explicitly opted out.
name: "standard transport with dead standard token sends nothing",
deviceId: standardToken,
voIPDeviceId: voIPToken,
sessionProps: map[string]string{model.SessionPropLastRemovedDeviceId: standardToken},
transport: model.PushTransportStandard,
expectSent: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
th := Setup(t).InitBasic(t)
handler := &testPushNotificationHandler{t: t, behavior: "simple"}
pushServer := httptest.NewServer(http.HandlerFunc(handler.handleReq))
defer pushServer.Close()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
_, err := th.App.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
DeviceId: tc.deviceId,
VoIPDeviceId: tc.voIPDeviceId,
Props: tc.sessionProps,
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, err)
msg := &model.PushNotification{
Type: model.PushTypeMessage,
SubType: model.PushSubTypeCalls,
Transport: tc.transport,
}
appErr := th.App.sendPushNotificationToAllSessions(th.Context, msg, th.BasicUser.Id, "")
require.Nil(t, appErr)
if !tc.expectSent {
assert.Never(t, func() bool {
return len(handler.notifications()) > 0
}, 500*time.Millisecond, 10*time.Millisecond, "expected no push notifications")
return
}
require.Eventually(t, func() bool {
return len(handler.notifications()) == 1
}, 2*time.Second, 10*time.Millisecond, "expected exactly one push notification")
notifications := handler.notifications()
assert.Equal(t, tc.expectedDeviceID, notifications[0].DeviceId)
assert.Equal(t, tc.expectedTransport, notifications[0].Transport)
})
}
}
func TestSendPushNotificationsVoIPRemoveTracking(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
handler := &testPushNotificationHandler{t: t, behavior: "always_remove"}
pushServer := httptest.NewServer(http.HandlerFunc(handler.handleReq))
defer pushServer.Close()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
session, err := th.App.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
DeviceId: model.PushNotifyAppleReactNative + ":standardtoken",
VoIPDeviceId: model.PushNotifyAppleReactNative + ":voiptoken",
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, err)
msg := &model.PushNotification{
Type: model.PushTypeMessage,
SubType: model.PushSubTypeCalls,
Transport: model.PushTransportVoIP,
}
appErr := th.App.sendPushNotificationToAllSessions(th.Context, msg, th.BasicUser.Id, "")
require.Nil(t, appErr)
require.Eventually(t, func() bool {
return len(handler.notifications()) == 1
}, 2*time.Second, 10*time.Millisecond)
// The recorded "last removed" prop should be VoIP-specific, not the standard one.
updated, err := th.App.GetSession(session.Token)
require.Nil(t, err)
assert.Equal(t, session.VoIPDeviceId, updated.Props[model.SessionPropLastRemovedVoIPDeviceId])
assert.Empty(t, updated.Props[model.SessionPropLastRemovedDeviceId], "standard remove prop must not be touched")
// Send a second VoIP push: the VoIP token is now marked removed, so the
// dispatch should downgrade to the standard transport and use DeviceId.
msg2 := &model.PushNotification{
Type: model.PushTypeMessage,
SubType: model.PushSubTypeCalls,
Transport: model.PushTransportVoIP,
}
appErr = th.App.sendPushNotificationToAllSessions(th.Context, msg2, th.BasicUser.Id, "")
require.Nil(t, appErr)
require.Eventually(t, func() bool {
return len(handler.notifications()) == 2
}, 2*time.Second, 10*time.Millisecond)
second := handler.notifications()[1]
assert.Equal(t, "standardtoken", second.DeviceId, "should have downgraded to the standard token")
assert.Equal(t, model.PushTransportStandard, second.Transport, "Transport should have been downgraded")
}
func TestShouldSendPushNotifications(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("should return true if forced", func(t *testing.T) {
user := &model.User{Id: model.NewId(), Email: "[email protected]", NotifyProps: make(map[string]string)}
user.NotifyProps[model.PushNotifyProp] = model.UserNotifyNone
post := &model.Post{UserId: user.Id, ChannelId: model.NewId()}
post.AddProp(model.PostPropsForceNotification, model.NewId())
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyNone, model.MarkUnreadNotifyProp: model.ChannelMarkUnreadMention}
status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: post.ChannelId}
result := th.App.ShouldSendPushNotification(th.Context, user, channelNotifyProps, false, status, post, false)
assert.True(t, result)
})
t.Run("should return false for silent post without force", func(t *testing.T) {
// User and channel are both configured to receive all-activity push;
// silent must suppress the push regardless of preferences.
user := &model.User{Id: model.NewId(), Email: "[email protected]", NotifyProps: make(map[string]string)}
user.NotifyProps[model.PushNotifyProp] = model.UserNotifyAll
post := &model.Post{UserId: model.NewId(), ChannelId: model.NewId()}
post.AddProp(model.PostPropsSilentNotification, true)
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyAll}
status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
result := th.App.ShouldSendPushNotification(th.Context, user, channelNotifyProps, true, status, post, false)
assert.False(t, result, "silent post must suppress push even when all-activity push is enabled")
})
t.Run("force notification overrides silent for push", func(t *testing.T) {
// Core override semantic: force must win over silent. User and
// channel push are both Off; force must still produce a push, even
// when silent is also set.
user := &model.User{Id: model.NewId(), Email: "[email protected]", NotifyProps: make(map[string]string)}
user.NotifyProps[model.PushNotifyProp] = model.UserNotifyNone
post := &model.Post{UserId: model.NewId(), ChannelId: model.NewId()}
post.AddProp(model.PostPropsSilentNotification, true)
post.AddProp(model.PostPropsForceNotification, model.NewId())
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyNone, model.MarkUnreadNotifyProp: model.ChannelMarkUnreadMention}
status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: post.ChannelId}
result := th.App.ShouldSendPushNotification(th.Context, user, channelNotifyProps, false, status, post, false)
assert.True(t, result, "force must win over silent — IsNotificationSuppressed returns false when both are set")
})
t.Run("should return false if force undefined", func(t *testing.T) {
user := &model.User{Id: model.NewId(), Email: "[email protected]", NotifyProps: make(map[string]string)}
user.NotifyProps[model.PushNotifyProp] = model.UserNotifyNone
post := &model.Post{UserId: user.Id, ChannelId: model.NewId()}
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyNone, model.MarkUnreadNotifyProp: model.ChannelMarkUnreadMention}
status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: post.ChannelId}
result := th.App.ShouldSendPushNotification(th.Context, user, channelNotifyProps, false, status, post, false)
assert.False(t, result)
})
t.Run("should return false if recipient is a bot", func(t *testing.T) {
botUser := &model.User{Id: model.NewId(), Email: "[email protected]", IsBot: true, NotifyProps: make(map[string]string)}
botUser.NotifyProps[model.PushNotifyProp] = model.UserNotifyAll
post := &model.Post{UserId: model.NewId(), ChannelId: model.NewId()}
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyAll}
status := &model.Status{UserId: botUser.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
result := th.App.ShouldSendPushNotification(th.Context, botUser, channelNotifyProps, true, status, post, false)
assert.False(t, result)
})
t.Run("should return false if recipient is a bot even with force notification", func(t *testing.T) {
botUser := &model.User{Id: model.NewId(), Email: "[email protected]", IsBot: true, NotifyProps: make(map[string]string)}
botUser.NotifyProps[model.PushNotifyProp] = model.UserNotifyAll
post := &model.Post{UserId: model.NewId(), ChannelId: model.NewId()}
post.AddProp(model.PostPropsForceNotification, model.NewId())
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyAll}
status := &model.Status{UserId: botUser.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
result := th.App.ShouldSendPushNotification(th.Context, botUser, channelNotifyProps, true, status, post, false)
assert.False(t, result)
})
t.Run("should return true for regular user even when post author is a bot", func(t *testing.T) {
botId := model.NewId()
regularUser := &model.User{Id: model.NewId(), Email: "[email protected]", IsBot: false, NotifyProps: make(map[string]string)}
regularUser.NotifyProps[model.PushNotifyProp] = model.UserNotifyAll
post := &model.Post{UserId: botId, ChannelId: model.NewId()}
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyAll}
status := &model.Status{UserId: regularUser.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
result := th.App.ShouldSendPushNotification(th.Context, regularUser, channelNotifyProps, true, status, post, false)
assert.True(t, result)
})
}
// testPushNotificationHandler is an HTTP handler to record push notifications
// being sent from the client.
// It records the number of requests sent to it, and stores all the requests
// to be verified later.
type testPushNotificationHandler struct {
t testing.TB
serialUserMap sync.Map
mut sync.RWMutex
behavior string
_numReqs int
_notifications []*model.PushNotification
_notificationAcks []*model.PushNotificationAck
}
// handleReq parses a push notification from the body, and stores it.
// It also sends an appropriate response depending on the behavior set.
// If the behavior is simple, it always sends an OK response. Otherwise,
// it alternates between an OK and a REMOVE response.
func (h *testPushNotificationHandler) handleReq(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case model.PushURLSuffix + "/send_push", model.PushURLSuffix + "/ack":
h.t.Helper()
// Don't do any checking if it's a benchmark
if _, ok := h.t.(*testing.B); ok {
h.printResponse(w, model.NewOkPushResponse())
return
}
var notification model.PushNotification
var notificationAck model.PushNotificationAck
var err error
if r.URL.Path == model.PushURLSuffix+"/send_push" {
if err = json.NewDecoder(r.Body).Decode(&notification); err != nil {
h.printResponse(w, model.NewErrorPushResponse("fail"))
return
}
// We verify that messages are being sent in order per-device.
if notification.DeviceId != "" {
if _, ok := h.serialUserMap.Load(notification.DeviceId); ok {
h.t.Fatalf("device id: %s being sent concurrently", notification.DeviceId)
}
h.serialUserMap.LoadOrStore(notification.DeviceId, true)
defer h.serialUserMap.Delete(notification.DeviceId)
}
} else {
if err = json.NewDecoder(r.Body).Decode(&notificationAck); err != nil {
h.printResponse(w, model.NewErrorPushResponse("fail"))
return
}
}
// Updating internal state.
h.mut.Lock()
defer h.mut.Unlock()
h._numReqs++
// Little bit of duplicate condition check so that we can check the in-order property
// first.
if r.URL.Path == model.PushURLSuffix+"/send_push" {
h._notifications = append(h._notifications, &notification)
} else {
h._notificationAcks = append(h._notificationAcks, &notificationAck)
}
var resp model.PushResponse
switch h.behavior {
case "simple":
resp = model.NewOkPushResponse()
case "fail":
resp = model.NewErrorPushResponse("device error")
case "always_remove":
resp = model.NewRemovePushResponse()
default:
// alternating between ok and remove response to test both code paths.
if h._numReqs%2 == 0 {
resp = model.NewOkPushResponse()
} else {
resp = model.NewRemovePushResponse()
}
}
h.printResponse(w, resp)
}
}
func (h *testPushNotificationHandler) printResponse(w http.ResponseWriter, resp model.PushResponse) {
jsonData, _ := json.Marshal(&resp)
fmt.Fprintln(w, string(jsonData))
}
func (h *testPushNotificationHandler) numReqs() int {
h.mut.RLock()
defer h.mut.RUnlock()
return h._numReqs
}
func (h *testPushNotificationHandler) notifications() []*model.PushNotification {
h.mut.RLock()
defer h.mut.RUnlock()
return h._notifications
}
func (h *testPushNotificationHandler) notificationAcks() []*model.PushNotificationAck {
h.mut.RLock()
defer h.mut.RUnlock()
return h._notificationAcks
}
func TestClearPushNotificationSync(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
handler := &testPushNotificationHandler{t: t, behavior: "simple"}
pushServer := httptest.NewServer(
http.HandlerFunc(handler.handleReq),
)
defer pushServer.Close()
sess1 := &model.Session{
Id: "id1",
UserId: "user1",
DeviceId: "test1",
ExpiresAt: model.GetMillis() + 100000,
}
sess2 := &model.Session{
Id: "id2",
UserId: "user1",
DeviceId: "test2",
ExpiresAt: model.GetMillis() + 100000,
}
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockUserStore.On("GetUnreadCount", mock.AnythingOfType("string"), mock.AnythingOfType("bool")).Return(int64(1), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
diagnosticID := model.NewId()
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
mockSessionStore := mocks.SessionStore{}
mockSessionStore.On("GetSessionsWithActiveDeviceIds", mock.AnythingOfType("string")).Return([]*model.Session{sess1, sess2}, nil)
mockSessionStore.On("UpdateProps", mock.Anything).Return(nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("Session").Return(&mockSessionStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
// When CRT is disabled
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDisabled
})
err := th.App.clearPushNotificationSync(th.Context, sess1.Id, "user1", "channel1", "")
require.Nil(t, err)
// Server side verification.
// We verify that 1 request has been sent, and also check the message contents.
require.Equal(t, 1, handler.numReqs())
assert.Equal(t, "channel1", handler.notifications()[0].ChannelId)
assert.Equal(t, model.PushTypeClear, handler.notifications()[0].Type)
// When CRT is enabled, Send badge count adding both "User unreads" + "User thread mentions"
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
mockPreferenceStore := mocks.PreferenceStore{}
mockPreferenceStore.On("Get", mock.AnythingOfType("string"), model.PreferenceCategoryDisplaySettings, model.PreferenceNameCollapsedThreadsEnabled).Return(&model.Preference{Value: "on"}, nil)
mockStore.On("Preference").Return(&mockPreferenceStore)
mockThreadStore := mocks.ThreadStore{}
mockThreadStore.On("GetTotalUnreadMentions", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.Anything).Return(int64(3), nil)
mockStore.On("Thread").Return(&mockThreadStore)
err = th.App.clearPushNotificationSync(th.Context, sess1.Id, "user1", "channel1", "")
require.Nil(t, err)
assert.Equal(t, handler.notifications()[1].Badge, 4)
}
func TestUpdateMobileAppBadgeSync(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
handler := &testPushNotificationHandler{t: t}
pushServer := httptest.NewServer(
http.HandlerFunc(handler.handleReq),
)
defer pushServer.Close()
sess1 := &model.Session{
Id: "id1",
UserId: "user1",
DeviceId: "test1",
ExpiresAt: model.GetMillis() + 100000,
}
sess2 := &model.Session{
Id: "id2",
UserId: "user1",
DeviceId: "test2",
ExpiresAt: model.GetMillis() + 100000,
}
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockUserStore.On("GetUnreadCount", mock.AnythingOfType("string"), mock.AnythingOfType("bool")).Return(int64(1), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
diagnosticID := model.NewId()
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
mockSessionStore := mocks.SessionStore{}
mockSessionStore.On("GetSessionsWithActiveDeviceIds", mock.AnythingOfType("string")).Return([]*model.Session{sess1, sess2}, nil)
mockSessionStore.On("UpdateProps", mock.Anything).Return(nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("Session").Return(&mockSessionStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDisabled
})
err := th.App.updateMobileAppBadgeSync(th.Context, "user1")
require.Nil(t, err)
// Server side verification.
// We verify that 2 requests have been sent, and also check the message contents.
require.Equal(t, 2, handler.numReqs())
assert.Equal(t, 1, handler.notifications()[0].ContentAvailable)
assert.Equal(t, model.PushTypeUpdateBadge, handler.notifications()[0].Type)
assert.Equal(t, 1, handler.notifications()[1].ContentAvailable)
assert.Equal(t, model.PushTypeUpdateBadge, handler.notifications()[1].Type)
}
func TestSendTestPushNotification(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
handler := &testPushNotificationHandler{t: t}
pushServer := httptest.NewServer(
http.HandlerFunc(handler.handleReq),
)
defer pushServer.Close()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
// Per mock definition, first time will send remove, second time will send OK
result := th.App.SendTestPushNotification(th.Context, "platform:id")
assert.Equal(t, "false", result)
result = th.App.SendTestPushNotification(th.Context, "platform:id")
assert.Equal(t, "true", result)
// Server side verification.
// We verify that 2 requests have been sent, and also check the message contents.
require.Equal(t, 2, handler.numReqs())
assert.Equal(t, model.PushTypeTest, handler.notifications()[0].Type)
assert.Equal(t, model.PushTypeTest, handler.notifications()[1].Type)
}
func TestSendAckToPushProxy(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
handler := &testPushNotificationHandler{t: t}
pushServer := httptest.NewServer(
http.HandlerFunc(handler.handleReq),
)
defer pushServer.Close()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
diagnosticID := model.NewId()
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
ack := &model.PushNotificationAck{
Id: "testid",
NotificationType: model.PushTypeMessage,
}
err := th.App.SendAckToPushProxy(th.Context, ack)
require.NoError(t, err)
// Server side verification.
// We verify that 1 request has been sent, and also check the message contents.
require.Equal(t, 1, handler.numReqs())
assert.Equal(t, ack.Id, handler.notificationAcks()[0].Id)
assert.Equal(t, ack.NotificationType, handler.notificationAcks()[0].NotificationType)
}
// TestAllPushNotifications is a master test which sends all various types
// of notifications and verifies they have been properly sent.
func TestAllPushNotifications(t *testing.T) {
mainHelper.Parallel(t)
if testing.Short() {
t.Skip("skipping all push notifications test in short mode")
}
th := Setup(t).InitBasic(t)
// Create 10 users, each having 2 sessions.
type userSession struct {
user *model.User
session *model.Session
}
var testData []userSession
for range 10 {
u := th.CreateUser(t)
sess, err := th.App.CreateSession(th.Context, &model.Session{
UserId: u.Id,
DeviceId: "deviceId" + u.Id,
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, err)
// We don't need to track the 2nd session.
_, err = th.App.CreateSession(th.Context, &model.Session{
UserId: u.Id,
DeviceId: "deviceId" + u.Id,
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, err)
_, err = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, u.Id)
require.Nil(t, err)
th.AddUserToChannel(t, u, th.BasicChannel)
testData = append(testData, userSession{
user: u,
session: sess,
})
}
handler := &testPushNotificationHandler{
t: t,
behavior: "simple",
}
pushServer := httptest.NewServer(
http.HandlerFunc(handler.handleReq),
)
defer pushServer.Close()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationContents = model.GenericNotification
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
var wg sync.WaitGroup
for i, data := range testData {
wg.Add(1)
// Ranging between 3 types of notifications.
switch i % 3 {
case 0:
go func(user model.User) {
defer wg.Done()
notification := &PostNotification{
Post: th.CreatePost(t, th.BasicChannel),
Channel: th.BasicChannel,
ProfileMap: map[string]*model.User{
user.Id: &user,
},
Sender: &user,
}
// testing all 3 notification types.
th.App.sendPushNotification(notification, &user, true, false, model.CommentsNotifyAny)
}(*data.user)
case 1:
go func(id string) {
defer wg.Done()
th.App.UpdateMobileAppBadge(id)
}(data.user.Id)
case 2:
go func(sessID, userID string) {
defer wg.Done()
th.App.clearPushNotification(sessID, userID, th.BasicChannel.Id, "")
}(data.session.Id, data.user.Id)
}
}
wg.Wait()
// Hack to let the worker goroutines complete.
time.Sleep(1 * time.Second)
// Server side verification.
assert.Equal(t, 17, handler.numReqs())
var numClears, numMessages, numUpdateBadges int
for _, n := range handler.notifications() {
switch n.Type {
case model.PushTypeClear:
numClears++
assert.Equal(t, th.BasicChannel.Id, n.ChannelId)
case model.PushTypeMessage:
numMessages++
assert.Equal(t, th.BasicChannel.Id, n.ChannelId)
assert.Contains(t, n.Message, "mentioned you")
case model.PushTypeUpdateBadge:
numUpdateBadges++
assert.Equal(t, "none", n.Sound)
assert.Equal(t, 1, n.ContentAvailable)
}
}
assert.Equal(t, 8, numMessages)
assert.Equal(t, 3, numClears)
assert.Equal(t, 6, numUpdateBadges)
}
func TestPushNotificationRace(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
memoryStore := config.NewTestMemoryStore()
mockStore := testlib.GetMockStoreForSetupFunctions()
// Playbooks DB job requires a plugin mock
pluginStore := mocks.PluginStore{}
pluginStore.On("List", mock.Anything, mock.Anything, mock.Anything).Return([]string{}, nil)
mockStore.On("Plugin").Return(&pluginStore)
mockPreferenceStore := mocks.PreferenceStore{}
mockPreferenceStore.On("Get",
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string")).
Return(&model.Preference{Value: "test"}, nil)
mockStore.On("Preference").Return(&mockPreferenceStore)
s := &Server{
Router: mux.NewRouter(),
}
var err error
s.platform, err = platform.New(
platform.ServiceConfig{},
platform.ConfigStore(memoryStore),
platform.SetFileStore(&fmocks.FileBackend{}),
platform.SetExportFileStore(&fmocks.FileBackend{}),
platform.StoreOverride(mockStore))
require.NoError(t, err)
ch, err := NewChannels(s)
require.NoError(t, err)
s.ch = ch
app := New(ServerConnector(s.Channels()))
require.NotPanics(t, func() {
s.createPushNotificationsHub(th.Context)
s.StopPushNotificationsHubWorkers()
// Now we start sending messages after the PN hub is shut down.
// We test all 3 notification types.
app.clearPushNotification("currentSessionId", "userId", "channelId", "")
app.UpdateMobileAppBadge("userId")
notification := &PostNotification{
Post: &model.Post{},
Channel: &model.Channel{},
ProfileMap: map[string]*model.User{
"userId": {},
},
Sender: &model.User{},
}
app.sendPushNotification(notification, &model.User{}, true, false, model.CommentsNotifyAny)
})
}
func TestPushNotificationAttachment(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
originalMessage := "hello world"
post := &model.Post{
Message: originalMessage,
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
AuthorName: "testuser",
Text: "test attachment",
Fallback: "fallback text",
},
},
},
}
user := &model.User{}
ch := &model.Channel{}
t.Run("The notification should contain the fallback message from the attachment", func(t *testing.T) {
pn := th.App.buildFullPushNotificationMessage(th.Context, "full", post, user, ch, ch.Name, "test", false, false, "")
assert.Equal(t, "test: hello world\nfallback text", pn.Message)
})
t.Run("The original post message should not be modified", func(t *testing.T) {
assert.Equal(t, originalMessage, post.Message)
})
}
// Run it with | grep -v '{"level"' to prevent spamming the console.
func BenchmarkPushNotificationThroughput(b *testing.B) {
th := SetupWithStoreMock(b)
handler := &testPushNotificationHandler{
t: b,
behavior: "simple",
}
pushServer := httptest.NewServer(
http.HandlerFunc(handler.handleReq),
)
defer pushServer.Close()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockUserStore.On("GetUnreadCount", mock.AnythingOfType("string"), mock.AnythingOfType("bool")).Return(int64(1), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
diagnosticID := model.NewId()
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
mockSessionStore := mocks.SessionStore{}
mockPreferenceStore := mocks.PreferenceStore{}
mockPreferenceStore.On("Get", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.Preference{Value: "test"}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("Session").Return(&mockSessionStore)
mockStore.On("Preference").Return(&mockPreferenceStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
// create 50 users, each having 2 sessions.
type userSession struct {
user *model.User
session *model.Session
}
var testData []userSession
for range 50 {
id := model.NewId()
u := &model.User{
Id: id,
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
}
sess1 := &model.Session{
Id: "id1",
UserId: u.Id,
DeviceId: "deviceId" + u.Id,
ExpiresAt: model.GetMillis() + 100000,
}
sess2 := &model.Session{
Id: "id2",
UserId: u.Id,
DeviceId: "deviceId" + u.Id,
ExpiresAt: model.GetMillis() + 100000,
}
mockSessionStore.On("GetSessionsWithActiveDeviceIds", u.Id).Return([]*model.Session{sess1, sess2}, nil)
mockSessionStore.On("UpdateProps", mock.Anything).Return(nil)
testData = append(testData, userSession{
user: u,
session: sess1,
})
}
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
*cfg.LogSettings.EnableConsole = false
})
ch := &model.Channel{
Id: model.NewId(),
CreateAt: model.GetMillis(),
Type: model.ChannelTypeOpen,
Name: "testch",
}
// We have an inner loop which ranges the testdata slice
// and we just repeat that.
then := time.Now()
cnt := 0
for b.Loop() {
cnt++
var wg sync.WaitGroup
for j, data := range testData {
wg.Add(1)
// Ranging between 3 types of notifications.
switch j % 3 {
case 0:
go func(user model.User) {
defer wg.Done()
post := &model.Post{
UserId: user.Id,
ChannelId: ch.Id,
Message: "test message",
CreateAt: model.GetMillis(),
}
notification := &PostNotification{
Post: post,
Channel: ch,
ProfileMap: map[string]*model.User{
user.Id: &user,
},
Sender: &user,
}
th.App.sendPushNotification(notification, &user, true, false, model.CommentsNotifyAny)
}(*data.user)
case 1:
go func(id string) {
defer wg.Done()
th.App.UpdateMobileAppBadge(id)
}(data.user.Id)
case 2:
go func(sessID, userID string) {
defer wg.Done()
th.App.clearPushNotification(sessID, userID, ch.Id, "")
}(data.session.Id, data.user.Id)
}
}
wg.Wait()
}
b.Logf("throughput: %f reqs/s", float64(len(testData)*cnt)/time.Since(then).Seconds())
time.Sleep(2 * time.Second)
}