Skip to content

Commit

Permalink
Add test notification tool (mattermost#28334)
Browse files Browse the repository at this point in the history
* Add test notification tool

* Add frontend styles

* Remove option from admin view

* Refactor create post and add translations

* Fix several CI errors

* Fix API and frontend snapshots

* Refactor trailing and leading icon on buttons

* Add different button states

* i18n-extract

* Fix wrong text

* Add tests

* Fix wrong string

* Fix test

* feat: E2E send test notifications (mattermost#28371)

* Refactor send desktop notification

* Address rest of the feedback

* Fix tests

* Add correct link

* Fix test

---------

Co-authored-by: Mattermost Build <[email protected]>
Co-authored-by: yasserfaraazkhan <[email protected]>
  • Loading branch information
3 people authored Nov 8, 2024
1 parent 30a6ddc commit 118d034
Show file tree
Hide file tree
Showing 31 changed files with 1,170 additions and 56 deletions.
24 changes: 24 additions & 0 deletions api/v4/source/system.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,30 @@
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/notifications/test:
post:
tags:
- system
summary: Send a test notification
description: >
Send a test notification to make sure you have your notification settings
configured correctly.
##### Permissions
Must be logged in.
operationId: TestNotification
responses:
"200":
description: Notification successfully sent
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/site_url/test:
post:
tags:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************

// Stage: @prod
// Group: @channels @notification

describe('Verify users can receive notification on browser', () => {
let offTopic: string;
const notificationMessage = 'If you received this test notification, it worked!';
before(() => {
cy.apiInitSetup({userPrefix: 'other', loginAfter: true}).then(({offTopicUrl}) => {
offTopic = offTopicUrl;
});
});

it('MM-T5631_1 should be able to receive notification when notifications are enabled on the browser', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('granted');
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();
cy.get('@notificationStub').should('be.called');
cy.get('@notificationStub').should((stub) => {
expect(stub).to.have.been.calledWithMatch(
'Direct Message',
Cypress.sinon.match({
body: '@@system-bot: If you received this test notification, it worked!',
tag: '@@system-bot: If you received this test notification, it worked!',
requireInteraction: false,
silent: false,
}),
);
});
cy.get('#accountSettingsHeader button.close').click();

// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});

it('MM-T5631_2 should not be able to receive notification when notifications are denied on the browser', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('denied');
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();

// * Assert that the Notification constructor was not called
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();

// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});

it('MM-T5631_3 should not trigger notification when permission is default (no decision made)', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('default');
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();

// * Assert that the Notification constructor was not called
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();

// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});

// Simulating macOS Focus Mode by suppressing the Notification constructor entirely
it('MM-T5631_4 should not show notification when Focus Mode is enabled (simulating no notification pop-up)', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('granted');

cy.window().then((win) => {
win.Notification = function() {
// Do nothing to simulate Focus Mode
};

cy.stub(win, 'Notification').as('notificationStub').callsFake(() => {
return null; // Prevent the notification from being created
});
});
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();

// * Assert that the Notification constructor was not called in Focus Mode
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();

// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});

it('should still recieve a test notification when user has set Global and Channel Notification preference to Nothing', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('default');

// # Mute Channel
cy.get('#channelHeaderTitle > span').click();
cy.get('#channelToggleMuteChannel').should('have.text', 'Mute Channel').click();
cy.get('#toggleMute').should('be.visible');

// # Set Desktop Notification preference to Nothing
cy.get('#CustomizeYourExperienceTour > button').click();
cy.get('#desktopAndMobileTitle').click();
cy.get('#sendDesktopNotificationsSection input[type=radio]').last().check();
cy.get('#saveSetting').click();
cy.wait(500);
triggertestNotification();

// * Assert that the Notification constructor was not called
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();

// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});
});

function triggertestNotification() {
cy.get('.sectionNoticeContent').scrollIntoView().should('be.visible');
cy.get('.btn-tertiary').should('be.visible').should('have.text', 'Troubleshooting docs');
cy.get('.btn-primary').should('be.visible').should('have.text', 'Send a test notification').click();
}
1 change: 1 addition & 0 deletions e2e-tests/cypress/tests/support/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import './fetch_commands';
import './keycloak_commands';
import './ldap_commands';
import './ldap_server_commands';
import './notification_commands';
import './okta_commands';
import './saml_commands';
import './shell';
Expand Down
38 changes: 38 additions & 0 deletions e2e-tests/cypress/tests/support/notification_commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import * as TIMEOUTS from '../fixtures/timeouts';

/**
* permission can be 'granted', 'denied', or 'default'
*/
function stubNotificationPermission(permission: string) {
cy.window().then((win) => {
cy.stub(win.Notification, 'permission').value(permission);
cy.stub(win.Notification, 'requestPermission').resolves(permission);
cy.stub(win, 'Notification').as('notificationStub').callsFake(() => {
return {
onclick: cy.stub().as('notificationOnClick'),
onerror: cy.stub().as('notificationOnError'),
};
});
});
}

/**
* Verify the system bot message was received
*/
function notificationMessage(notificationMessage: string) {
// * Assert the unread count is correct
cy.get('.SidebarLink:contains(system-bot)').find('#unreadMentions').as('unreadCount').should('be.visible').should('have.text', '1');
cy.get('.SidebarLink:contains(system-bot)').find('.Avatar').should('exist').click().wait(TIMEOUTS.HALF_SEC);
cy.get('@unreadCount').should('not.exist');

// * Assert the notification message
cy.getLastPostId().then((postId) => {
cy.get(`#postMessageText_${postId}`).scrollIntoView().should('be.visible').should('have.text', notificationMessage);
});
}

Cypress.Commands.add('stubNotificationPermission', stubNotificationPermission);
Cypress.Commands.add('verifySystemBotMessageRecieved', notificationMessage);
11 changes: 11 additions & 0 deletions server/channels/api4/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (api *API) InitSystem() {
api.BaseRoutes.System.Handle("/timezones", api.APISessionRequired(getSupportedTimezones)).Methods(http.MethodGet)

api.BaseRoutes.APIRoot.Handle("/audits", api.APISessionRequired(getAudits)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/notifications/test", api.APISessionRequired(testNotifications)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/email/test", api.APISessionRequired(testEmail)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/site_url/test", api.APISessionRequired(testSiteURL)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/file/s3_test", api.APISessionRequired(testS3)).Methods(http.MethodPost)
Expand Down Expand Up @@ -232,6 +233,16 @@ func getSystemPing(c *Context, w http.ResponseWriter, r *http.Request) {
}
}

func testNotifications(c *Context, w http.ResponseWriter, r *http.Request) {
_, err := c.App.SendTestMessage(c.AppContext, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}

ReturnStatusOK(w)
}

func testEmail(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
Expand Down
1 change: 1 addition & 0 deletions server/channels/app/app_iface.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions server/channels/app/notification_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,10 @@ func (a *App) getMobileAppSessions(userID string) ([]*model.Session, *model.AppE
}

func (a *App) ShouldSendPushNotification(user *model.User, channelNotifyProps model.StringMap, wasMentioned bool, status *model.Status, post *model.Post, isGM bool) bool {
if prop := post.GetProp(model.PostPropsForceNotification); prop != nil && prop != "" {
return true
}

if notifyPropsAllowedReason := DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, wasMentioned, isGM); notifyPropsAllowedReason != "" {
a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypePush, notifyPropsAllowedReason, model.NotificationNoPlatform)
a.NotificationsLog().Debug("Notification not sent - notify props",
Expand Down
33 changes: 33 additions & 0 deletions server/channels/app/notification_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,39 @@ func TestSendPushNotifications(t *testing.T) {
})
}

func TestShouldSendPushNotifications(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
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(user, channelNotifyProps, false, status, post, false)
assert.True(t, result)
})

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(user, channelNotifyProps, false, status, post, false)
assert.False(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
Expand Down
22 changes: 22 additions & 0 deletions server/channels/app/opentracing/opentracing_layer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions server/channels/app/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel
post.AddProp(model.PostPropsFromBot, "true")
}

if flags.ForceNotification {
post.AddProp(model.PostPropsForceNotification, model.NewId())
}

if c.Session().IsOAuth {
post.AddProp(model.PostPropsFromOAuthApp, "true")
}
Expand Down Expand Up @@ -2722,3 +2726,34 @@ func (a *App) CleanUpAfterPostDeletion(c request.CTX, post *model.Post, deleteBy

return nil
}

func (a *App) SendTestMessage(c request.CTX, userID string) (*model.Post, *model.AppError) {
bot, err := a.GetSystemBot(c)
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_bot", nil, "", http.StatusInternalServerError).Wrap(err)
}

channel, err := a.GetOrCreateDirectChannel(c, userID, bot.UserId)
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_channel", nil, "", http.StatusInternalServerError).Wrap(err)
}

user, err := a.GetUser(userID)
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_user", nil, "", http.StatusInternalServerError).Wrap(err)
}
T := i18n.GetUserTranslations(user.Locale)
post := &model.Post{
ChannelId: channel.Id,
Message: T("app.notifications.send_test_message.message_body"),
Type: model.PostTypeDefault,
UserId: bot.UserId,
}

post, err = a.CreatePost(c, post, channel, model.CreatePostFlags{ForceNotification: true})
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.create_post", nil, "", http.StatusInternalServerError).Wrap(err)
}

return post, nil
}
Loading

0 comments on commit 118d034

Please sign in to comment.