Content flagging actions implementation tests (#35035)

* Added tests for TestGetFlaggingConfiguration

* added tests for RemoveFlaggedPost

* added tests for KeepFlaggedPost

* Added tests for scrubPost and keep flagged post APp layer functions

* Added tests for DeleteAllForPost

* added tests for DeleteAllPostRemindersForPost

* Added tests for DataSpillageFooter

* Added tests for KeepRemoveFlaggedMessageConfirmationModal

* Added tests for KeepRemoveFlaggedMessageConfirmationModal

* Fixed lint errors

* lint fix

* Fixed query param

* fixed tests

* Used middleware to check for content flagging basic checks

* Added TestRequireContentFlaggingEnabled

* Updated tests

* refactoring to reduce code duplication

* review fixes

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Harshil Sharma 2026-02-23 06:15:40 +05:30 committed by GitHub
parent 033867a344
commit b96f7c1a8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1855 additions and 338 deletions

View file

@ -20,18 +20,19 @@ func (api *API) InitContentFlagging() {
return
}
api.BaseRoutes.ContentFlagging.Handle("/flag/config", api.APISessionRequired(getFlaggingConfiguration)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/status", api.APISessionRequired(getTeamPostFlaggingFeatureStatus)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/flag", api.APISessionRequired(flagPost)).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/fields", api.APISessionRequired(getContentFlaggingFields)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/field_values", api.APISessionRequired(getPostPropertyValues)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(getFlaggedPost)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(removeFlaggedPost)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(keepFlaggedPost)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/flag/config", api.APISessionRequired(contentFlaggingRequired(getFlaggingConfiguration))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/status", api.APISessionRequired(contentFlaggingRequired(getTeamPostFlaggingFeatureStatus))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/flag", api.APISessionRequired(contentFlaggingRequired(flagPost))).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/fields", api.APISessionRequired(contentFlaggingRequired(getContentFlaggingFields))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/field_values", api.APISessionRequired(contentFlaggingRequired(getPostPropertyValues))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(contentFlaggingRequired(getFlaggedPost))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(contentFlaggingRequired(removeFlaggedPost))).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(contentFlaggingRequired(keepFlaggedPost))).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/reviewers/search", api.APISessionRequired(contentFlaggingRequired(searchReviewers))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/assign/{content_reviewer_id:[A-Za-z0-9]+}", api.APISessionRequired(contentFlaggingRequired(assignFlaggedPostReviewer))).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(saveContentFlaggingSettings)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(getContentFlaggingSettings)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/reviewers/search", api.APISessionRequired(searchReviewers)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/assign/{content_reviewer_id:[A-Za-z0-9]+}", api.APISessionRequired(assignFlaggedPostReviewer)).Methods(http.MethodPost)
}
func requireContentFlaggingAvailable(c *Context) {
@ -54,6 +55,17 @@ func requireContentFlaggingEnabled(c *Context) {
}
}
func contentFlaggingRequired(h handlerFunc) handlerFunc {
return func(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
h(c, w, r)
}
}
func requireTeamContentReviewer(c *Context, userId, teamId string) {
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, teamId)
if appErr != nil {
@ -81,7 +93,6 @@ func requireFlaggedPost(c *Context, postId string) {
}
func getFlaggingConfiguration(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
@ -112,7 +123,6 @@ func getFlaggingConfiguration(c *Context, w http.ResponseWriter, r *http.Request
}
func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
@ -145,7 +155,6 @@ func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http
}
func flagPost(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
@ -226,7 +235,6 @@ func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings, as
}
func getContentFlaggingFields(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
@ -250,7 +258,6 @@ func getContentFlaggingFields(c *Context, w http.ResponseWriter, r *http.Request
}
func getPostPropertyValues(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
@ -294,7 +301,6 @@ func getPostPropertyValues(c *Context, w http.ResponseWriter, r *http.Request) {
}
func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
@ -406,7 +412,6 @@ func keepFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
func keepRemoveFlaggedPostChecks(c *Context, r *http.Request) (*model.FlagContentActionRequest, string, *model.Post) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return nil, "", nil
}
@ -525,7 +530,6 @@ func getContentFlaggingSettings(c *Context, w http.ResponseWriter, r *http.Reque
}
func searchReviewers(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
@ -557,7 +561,6 @@ func searchReviewers(c *Context, w http.ResponseWriter, r *http.Request) {
}
func assignFlaggedPostReviewer(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,23 @@ func setBaseConfig(th *TestHelper) *model.AppError {
return nil
}
func searchPropertyValue(t *testing.T, th *TestHelper, postId, fieldName string) []*model.PropertyValue {
t.Helper()
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
values, err := th.Server.propertyAccessService.SearchPropertyValues("", groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{postId},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[fieldName].ID,
})
require.NoError(t, err)
return values
}
func setupFlaggedPost(t *testing.T, th *TestHelper) *model.Post {
post := th.CreatePost(t, th.BasicChannel)
@ -2465,7 +2482,7 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) {
require.Eventually(t, func() bool {
appErr = th.App.PermanentDeleteFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
return true
return appErr == nil
}, 5*time.Second, 200*time.Millisecond)
// Verify post was deleted and status updated
@ -2506,7 +2523,7 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) {
require.Eventually(t, func() bool {
appErr = th.App.PermanentDeleteFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, editedPost)
require.Nil(t, appErr)
return true
return appErr == nil
}, 5*time.Second, 200*time.Millisecond)
// Verify status was updated
@ -2545,7 +2562,7 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) {
deletedPost, appErr = th.App.GetSinglePost(th.Context, post.Id, true)
require.Nil(t, appErr)
require.True(t, deletedPost.DeleteAt > 0)
return true
return appErr == nil
}, 5*time.Second, 200*time.Millisecond)
actionRequest := &model.FlagContentActionRequest{
@ -2567,3 +2584,508 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) {
require.Equal(t, `"`+model.ContentFlaggingStatusRemoved+`"`, string(statusValue.Value))
})
}
func TestKeepFlaggedPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.Nil(t, setBaseConfig(th))
t.Run("should successfully keep pending flagged post", func(t *testing.T) {
post := setupFlaggedPost(t, th)
actionRequest := &model.FlagContentActionRequest{
Comment: "This post is acceptable after review",
}
appErr := th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
// Verify post still exists and is not deleted
updatedPost, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
require.Nil(t, appErr)
require.Equal(t, int64(0), updatedPost.DeleteAt)
// Verify status was updated to retained
statusValue, appErr := th.App.GetPostContentFlaggingPropertyValue(post.Id, ContentFlaggingPropertyNameStatus)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusRetained+`"`, string(statusValue.Value))
// Verify actor properties were created
// Check actor user property
actorValues := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameActorUserID)
require.Len(t, actorValues, 1)
require.Equal(t, `"`+th.SystemAdminUser.Id+`"`, string(actorValues[0].Value))
// Check actor comment property
commentValues := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameActorComment)
require.Len(t, commentValues, 1)
require.Equal(t, `"This post is acceptable after review"`, string(commentValues[0].Value))
// Check action time property
timeValues := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameActionTime)
require.Len(t, timeValues, 1)
var actionTime int64
err := json.Unmarshal(timeValues[0].Value, &actionTime)
require.NoError(t, err)
require.True(t, actionTime > 0)
})
t.Run("should successfully keep and restore hidden flagged post", func(t *testing.T) {
baseConfig := getBaseConfig(th)
baseConfig.AdditionalSettings.HideFlaggedContent = model.NewPointer(true)
appErr := th.App.SaveContentFlaggingConfig(baseConfig)
require.Nil(t, appErr)
post := th.CreatePost(t, th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
// Flag the post (this will hide/delete it due to HideFlaggedContent setting)
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
var hiddenPost *model.Post
require.Eventually(t, func() bool {
// Get the updated post (should be deleted/hidden)
hiddenPost, appErr = th.App.GetSinglePost(th.Context, post.Id, true)
require.Nil(t, appErr)
return hiddenPost.DeleteAt > 0
}, 5*time.Second, 200*time.Millisecond)
actionRequest := &model.FlagContentActionRequest{
Comment: "Restoring this post after review",
}
appErr = th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, hiddenPost)
require.Nil(t, appErr)
// Verify post was restored (DeleteAt should be 0)
restoredPost, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
require.Nil(t, appErr)
require.Equal(t, int64(0), restoredPost.DeleteAt)
// Verify status was updated to retained
statusValue, appErr := th.App.GetPostContentFlaggingPropertyValue(post.Id, ContentFlaggingPropertyNameStatus)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusRetained+`"`, string(statusValue.Value))
})
t.Run("should successfully keep assigned flagged post", func(t *testing.T) {
// Reset config to not hide flagged content
require.Nil(t, setBaseConfig(th))
post := setupFlaggedPost(t, th)
// Assign the post to a reviewer first
appErr := th.App.AssignFlaggedPostReviewer(th.Context, post.Id, th.BasicChannel.TeamId, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, appErr)
actionRequest := &model.FlagContentActionRequest{
Comment: "Assigned post is acceptable",
}
appErr = th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
// Verify status was updated to retained
statusValue, appErr := th.App.GetPostContentFlaggingPropertyValue(post.Id, ContentFlaggingPropertyNameStatus)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusRetained+`"`, string(statusValue.Value))
})
t.Run("should fail when trying to keep already removed post", func(t *testing.T) {
post := setupFlaggedPost(t, th)
// Set status to removed
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
statusValue, appErr := th.App.GetPostContentFlaggingPropertyValue(post.Id, ContentFlaggingPropertyNameStatus)
require.Nil(t, appErr)
statusValue.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRemoved))
_, err := th.App.Srv().propertyAccessService.UpdatePropertyValue("", groupId, statusValue)
require.NoError(t, err)
actionRequest := &model.FlagContentActionRequest{
Comment: "Trying to keep already removed post",
}
appErr = th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.NotNil(t, appErr)
require.Equal(t, "api.content_flagging.error.post_not_in_progress", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("should fail when trying to keep already retained post", func(t *testing.T) {
post := setupFlaggedPost(t, th)
// Set status to retained
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
statusValue, appErr := th.App.GetPostContentFlaggingPropertyValue(post.Id, ContentFlaggingPropertyNameStatus)
require.Nil(t, appErr)
statusValue.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRetained))
_, err := th.App.Srv().propertyAccessService.UpdatePropertyValue("", groupId, statusValue)
require.NoError(t, err)
actionRequest := &model.FlagContentActionRequest{
Comment: "Trying to keep already retained post",
}
appErr = th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.NotNil(t, appErr)
require.Equal(t, "api.content_flagging.error.post_not_in_progress", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("should fail when trying to keep non-flagged post", func(t *testing.T) {
post := th.CreatePost(t, th.BasicChannel)
actionRequest := &model.FlagContentActionRequest{
Comment: "Trying to keep non-flagged post",
}
appErr := th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.NotNil(t, appErr)
require.Equal(t, http.StatusNotFound, appErr.StatusCode)
})
t.Run("should handle empty comment", func(t *testing.T) {
post := setupFlaggedPost(t, th)
actionRequest := &model.FlagContentActionRequest{
Comment: "",
}
appErr := th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
// Verify empty comment was stored
commentValues := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameActorComment)
require.Len(t, commentValues, 1)
require.Equal(t, `""`, string(commentValues[0].Value))
})
t.Run("should handle special characters in comment", func(t *testing.T) {
post := setupFlaggedPost(t, th)
specialComment := "Comment with @mentions #channels ~teams & <script>alert('xss')</script>"
actionRequest := &model.FlagContentActionRequest{
Comment: specialComment,
}
appErr := th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
// Verify special characters were properly escaped and stored
commentValues := searchPropertyValue(t, th, post.Id, contentFlaggingPropertyNameActorComment)
require.Len(t, commentValues, 1)
var storedComment string
err := json.Unmarshal(commentValues[0].Value, &storedComment)
require.NoError(t, err)
require.Equal(t, specialComment, storedComment)
})
t.Run("should preserve file attachments when keeping flagged post", func(t *testing.T) {
// Create a post with file attachments
post := th.CreatePost(t, th.BasicChannel)
// Create some file infos and associate them with the post
fileInfo1 := &model.FileInfo{
Id: model.NewId(),
PostId: post.Id,
CreatorId: post.UserId,
Path: "test/file1.txt",
Name: "file1.txt",
Size: 100,
}
fileInfo2 := &model.FileInfo{
Id: model.NewId(),
PostId: post.Id,
CreatorId: post.UserId,
Path: "test/file2.txt",
Name: "file2.txt",
Size: 200,
}
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, fileInfo1)
require.NoError(t, err)
_, err = th.App.Srv().Store().FileInfo().Save(th.Context, fileInfo2)
require.NoError(t, err)
// Update post to include file IDs
post.FileIds = []string{fileInfo1.Id, fileInfo2.Id}
_, _, appErr := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{})
require.Nil(t, appErr)
// Flag the post
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
actionRequest := &model.FlagContentActionRequest{
Comment: "Post with files is acceptable",
}
require.Eventually(t, func() bool {
appErr = th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
return appErr == nil
}, 5*time.Second, 200*time.Millisecond)
// Verify post was retained
statusValue, appErr := th.App.GetPostContentFlaggingPropertyValue(post.Id, ContentFlaggingPropertyNameStatus)
require.Nil(t, appErr)
require.Equal(t, `"`+model.ContentFlaggingStatusRetained+`"`, string(statusValue.Value))
// Verify file infos are still present (not deleted)
files, err := th.App.Srv().Store().FileInfo().GetByIds([]string{fileInfo1.Id, fileInfo2.Id}, false, false)
require.NoError(t, err)
require.Len(t, files, 2, "File attachments should be preserved when keeping flagged post")
})
t.Run("should preserve edit history when keeping flagged post", func(t *testing.T) {
post := th.CreatePost(t, th.BasicChannel)
// Create edit history for the post
editedPost := post.Clone()
editedPost.Message = "Edited message"
editedPost.EditAt = model.GetMillis()
_, _, appErr := th.App.UpdatePost(th.Context, editedPost, &model.UpdatePostOptions{})
require.Nil(t, appErr)
// Verify edit history exists before flagging
editHistoryBefore, appErr := th.App.GetEditHistoryForPost(post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, editHistoryBefore)
editHistoryPostId := editHistoryBefore[0].Id
// Flag the post
flagData := model.FlagContentRequest{
Reason: "inappropriate",
Comment: "This post is inappropriate",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
actionRequest := &model.FlagContentActionRequest{
Comment: "Post with edit history is acceptable",
}
require.Eventually(t, func() bool {
appErr = th.App.KeepFlaggedPost(th.Context, actionRequest, th.SystemAdminUser.Id, editedPost)
require.Nil(t, appErr)
return appErr == nil
}, 5*time.Second, 200*time.Millisecond)
// Verify status was updated to retained
statusValue, appErr := th.App.GetPostContentFlaggingPropertyValue(editedPost.Id, ContentFlaggingPropertyNameStatus)
require.Nil(t, appErr)
var stringValue string
err := json.Unmarshal(statusValue.Value, &stringValue)
require.NoError(t, err)
require.Equal(t, model.ContentFlaggingStatusRetained, stringValue)
// Verify edit history is still present (not deleted)
editHistoryAfter, appErr := th.App.GetEditHistoryForPost(post.Id)
require.Nil(t, appErr, "Edit history should be preserved when keeping flagged post")
require.NotEmpty(t, editHistoryAfter)
require.Equal(t, editHistoryPostId, editHistoryAfter[0].Id)
})
}
func TestScrubPost(t *testing.T) {
expectedMessage := "*Content deleted as part of Content Flagging review process*"
t.Run("should scrub all post content fields", func(t *testing.T) {
post := &model.Post{
Id: model.NewId(),
Message: "This is the original message with sensitive content",
MessageSource: "This is the original message source",
Hashtags: "#hashtag1 #hashtag2",
FileIds: []string{"file1", "file2", "file3"},
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{Type: "link", URL: "https://example.com"},
},
Files: []*model.FileInfo{
{Id: "file1", Name: "test.png"},
},
},
}
post.SetProps(map[string]any{
"custom_prop": "custom_value",
"another_prop": 123,
"sensitive_data": "should be removed",
})
scrubPost(post)
require.Equal(t, expectedMessage, post.Message)
require.Equal(t, expectedMessage, post.MessageSource)
require.Equal(t, "", post.Hashtags)
require.Nil(t, post.Metadata)
require.Empty(t, post.FileIds)
require.NotNil(t, post.GetProps())
require.Empty(t, post.GetProps())
})
t.Run("should handle post with empty fields", func(t *testing.T) {
post := &model.Post{
Id: model.NewId(),
Message: "",
MessageSource: "",
Hashtags: "",
FileIds: []string{},
Metadata: nil,
}
post.SetProps(make(map[string]any))
scrubPost(post)
require.Equal(t, expectedMessage, post.Message)
require.Equal(t, expectedMessage, post.MessageSource)
require.Equal(t, "", post.Hashtags)
require.Nil(t, post.Metadata)
require.Empty(t, post.FileIds)
require.NotNil(t, post.GetProps())
require.Empty(t, post.GetProps())
})
t.Run("should handle post with nil FileIds", func(t *testing.T) {
post := &model.Post{
Id: model.NewId(),
Message: "Test message",
FileIds: nil,
}
scrubPost(post)
require.Equal(t, expectedMessage, post.Message)
require.NotNil(t, post.FileIds)
require.Empty(t, post.FileIds)
})
t.Run("should handle post with nil Props", func(t *testing.T) {
post := &model.Post{
Id: model.NewId(),
Message: "Test message",
}
// Props is nil by default
scrubPost(post)
require.Equal(t, expectedMessage, post.Message)
require.NotNil(t, post.GetProps())
require.Empty(t, post.GetProps())
})
t.Run("should preserve non-content fields", func(t *testing.T) {
postId := model.NewId()
userId := model.NewId()
channelId := model.NewId()
rootId := model.NewId()
createAt := model.GetMillis()
updateAt := model.GetMillis()
editAt := model.GetMillis()
post := &model.Post{
Id: postId,
CreateAt: createAt,
UpdateAt: updateAt,
EditAt: editAt,
DeleteAt: 0,
UserId: userId,
ChannelId: channelId,
RootId: rootId,
OriginalId: "",
Message: "Original message to be scrubbed",
MessageSource: "Original source",
Type: model.PostTypeDefault,
Hashtags: "#test",
FileIds: []string{"file1"},
}
post.SetProps(map[string]any{"key": "value"})
scrubPost(post)
// Verify content fields are scrubbed
require.Equal(t, expectedMessage, post.Message)
require.Equal(t, expectedMessage, post.MessageSource)
require.Equal(t, "", post.Hashtags)
require.Nil(t, post.Metadata)
require.Empty(t, post.FileIds)
require.Empty(t, post.GetProps())
// Verify non-content fields are preserved
require.Equal(t, postId, post.Id)
require.Equal(t, createAt, post.CreateAt)
require.Equal(t, updateAt, post.UpdateAt)
require.Equal(t, editAt, post.EditAt)
require.Equal(t, userId, post.UserId)
require.Equal(t, channelId, post.ChannelId)
require.Equal(t, rootId, post.RootId)
require.Equal(t, model.PostTypeDefault, post.Type)
})
t.Run("should handle post with special characters in message", func(t *testing.T) {
post := &model.Post{
Id: model.NewId(),
Message: "Message with <script>alert('xss')</script> and @mentions #hashtags ~channels",
MessageSource: "Source with unicode: 你好世界 🎉 émojis",
Hashtags: "#特殊 #émoji",
}
scrubPost(post)
require.Equal(t, expectedMessage, post.Message)
require.Equal(t, expectedMessage, post.MessageSource)
require.Equal(t, "", post.Hashtags)
})
t.Run("should handle post with complex Metadata", func(t *testing.T) {
post := &model.Post{
Id: model.NewId(),
Message: "Test message",
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{Type: "link", URL: "https://example1.com"},
{Type: "link", URL: "https://example2.com"},
},
Emojis: []*model.Emoji{
{Id: "emoji1", Name: "custom_emoji"},
},
Files: []*model.FileInfo{
{Id: "file1", Name: "document.pdf", Size: 1024},
{Id: "file2", Name: "image.png", Size: 2048},
},
Reactions: []*model.Reaction{
{UserId: "user1", PostId: "post1", EmojiName: "thumbsup"},
},
},
}
scrubPost(post)
require.Equal(t, expectedMessage, post.Message)
require.Nil(t, post.Metadata)
})
}

View file

@ -19,6 +19,7 @@ func TestPostAcknowledgementsStore(t *testing.T, rctx request.CTX, ss store.Stor
t.Run("GetForPosts", func(t *testing.T) { testPostAcknowledgementsStoreGetForPosts(t, rctx, ss) })
t.Run("BatchSave", func(t *testing.T) { testPostAcknowledgementsStoreBatchSave(t, rctx, ss) })
t.Run("BatchDelete", func(t *testing.T) { testPostAcknowledgementsStoreBatchDelete(t, rctx, ss) })
t.Run("DeleteAllForPost", func(t *testing.T) { testPostAcknowledgementsStoreDeleteAllForPost(t, rctx, ss) })
}
func testPostAcknowledgementsStoreSave(t *testing.T, rctx request.CTX, ss store.Store) {
@ -419,3 +420,163 @@ func testPostAcknowledgementsStoreBatchDelete(t *testing.T, rctx request.CTX, ss
require.NoError(t, err)
})
}
func testPostAcknowledgementsStoreDeleteAllForPost(t *testing.T, rctx request.CTX, ss store.Store) {
userID1 := model.NewId()
userID2 := model.NewId()
userID3 := model.NewId()
t.Run("should permanently delete all acknowledgements for a post", func(t *testing.T) {
p1 := model.Post{}
p1.ChannelId = model.NewId()
p1.UserId = model.NewId()
p1.Message = NewTestID()
p1.Metadata = &model.PostMetadata{
Priority: &model.PostPriority{
Priority: model.NewPointer("important"),
RequestedAck: model.NewPointer(true),
PersistentNotifications: model.NewPointer(false),
},
}
post, err := ss.Post().Save(rctx, &p1)
require.NoError(t, err)
// Create multiple acknowledgements
ack1 := &model.PostAcknowledgement{PostId: post.Id, UserId: userID1, AcknowledgedAt: 0, ChannelId: post.ChannelId}
_, err = ss.PostAcknowledgement().SaveWithModel(ack1)
require.NoError(t, err)
ack2 := &model.PostAcknowledgement{PostId: post.Id, UserId: userID2, AcknowledgedAt: 0, ChannelId: post.ChannelId}
_, err = ss.PostAcknowledgement().SaveWithModel(ack2)
require.NoError(t, err)
ack3 := &model.PostAcknowledgement{PostId: post.Id, UserId: userID3, AcknowledgedAt: 0, ChannelId: post.ChannelId}
_, err = ss.PostAcknowledgement().SaveWithModel(ack3)
require.NoError(t, err)
// Verify acknowledgements were created
acks, err := ss.PostAcknowledgement().GetForPost(post.Id)
require.NoError(t, err)
require.Len(t, acks, 3)
// Delete all acknowledgements for the post
err = ss.PostAcknowledgement().DeleteAllForPost(post.Id)
require.NoError(t, err)
// Verify all acknowledgements were permanently deleted
acks, err = ss.PostAcknowledgement().GetForPost(post.Id)
require.NoError(t, err)
require.Empty(t, acks)
// Verify they are truly deleted and not just soft-deleted (AcknowledgedAt = 0)
// by checking GetForPostSince with inclDeleted = true
acksWithDeleted, err := ss.PostAcknowledgement().GetForPostSince(post.Id, 0, "", true)
require.NoError(t, err)
require.Empty(t, acksWithDeleted, "Acknowledgements should be permanently deleted, not soft-deleted")
})
t.Run("should only delete acknowledgements for the specified post", func(t *testing.T) {
// Create two posts
p1 := model.Post{}
p1.ChannelId = model.NewId()
p1.UserId = model.NewId()
p1.Message = NewTestID()
post1, err := ss.Post().Save(rctx, &p1)
require.NoError(t, err)
p2 := model.Post{}
p2.ChannelId = model.NewId()
p2.UserId = model.NewId()
p2.Message = NewTestID()
post2, err := ss.Post().Save(rctx, &p2)
require.NoError(t, err)
// Create acknowledgements for both posts
ack1 := &model.PostAcknowledgement{PostId: post1.Id, UserId: userID1, AcknowledgedAt: 0, ChannelId: post1.ChannelId}
_, err = ss.PostAcknowledgement().SaveWithModel(ack1)
require.NoError(t, err)
ack2 := &model.PostAcknowledgement{PostId: post1.Id, UserId: userID2, AcknowledgedAt: 0, ChannelId: post1.ChannelId}
_, err = ss.PostAcknowledgement().SaveWithModel(ack2)
require.NoError(t, err)
ack3 := &model.PostAcknowledgement{PostId: post2.Id, UserId: userID1, AcknowledgedAt: 0, ChannelId: post2.ChannelId}
ack3, err = ss.PostAcknowledgement().SaveWithModel(ack3)
require.NoError(t, err)
ack4 := &model.PostAcknowledgement{PostId: post2.Id, UserId: userID2, AcknowledgedAt: 0, ChannelId: post2.ChannelId}
ack4, err = ss.PostAcknowledgement().SaveWithModel(ack4)
require.NoError(t, err)
// Delete all acknowledgements for post1 only
err = ss.PostAcknowledgement().DeleteAllForPost(post1.Id)
require.NoError(t, err)
// Verify post1 acknowledgements are deleted
acks1, err := ss.PostAcknowledgement().GetForPost(post1.Id)
require.NoError(t, err)
require.Empty(t, acks1)
// Verify post2 acknowledgements are still present
acks2, err := ss.PostAcknowledgement().GetForPost(post2.Id)
require.NoError(t, err)
require.Len(t, acks2, 2)
require.ElementsMatch(t, acks2, []*model.PostAcknowledgement{ack3, ack4})
})
t.Run("should not error when deleting from post with no acknowledgements", func(t *testing.T) {
p1 := model.Post{}
p1.ChannelId = model.NewId()
p1.UserId = model.NewId()
p1.Message = NewTestID()
post, err := ss.Post().Save(rctx, &p1)
require.NoError(t, err)
// Verify no acknowledgements exist
acks, err := ss.PostAcknowledgement().GetForPost(post.Id)
require.NoError(t, err)
require.Empty(t, acks)
// Delete should not error
err = ss.PostAcknowledgement().DeleteAllForPost(post.Id)
require.NoError(t, err)
})
t.Run("should not error when deleting from non-existent post", func(t *testing.T) {
nonExistentPostId := model.NewId()
// Delete should not error even for non-existent post
err := ss.PostAcknowledgement().DeleteAllForPost(nonExistentPostId)
require.NoError(t, err)
})
t.Run("should delete both active and soft-deleted acknowledgements", func(t *testing.T) {
p1 := model.Post{}
p1.ChannelId = model.NewId()
p1.UserId = model.NewId()
p1.Message = NewTestID()
post, err := ss.Post().Save(rctx, &p1)
require.NoError(t, err)
// Create active acknowledgement
ack1 := &model.PostAcknowledgement{PostId: post.Id, UserId: userID1, AcknowledgedAt: 0, ChannelId: post.ChannelId}
_, err = ss.PostAcknowledgement().SaveWithModel(ack1)
require.NoError(t, err)
// Create another acknowledgement and soft-delete it
ack2 := &model.PostAcknowledgement{PostId: post.Id, UserId: userID2, AcknowledgedAt: 0, ChannelId: post.ChannelId}
ack2, err = ss.PostAcknowledgement().SaveWithModel(ack2)
require.NoError(t, err)
err = ss.PostAcknowledgement().Delete(ack2) // Soft delete sets AcknowledgedAt to 0
require.NoError(t, err)
// Verify we have active acknowledgements
activeAcks, err := ss.PostAcknowledgement().GetForPost(post.Id)
require.NoError(t, err)
require.Len(t, activeAcks, 1)
// Delete all acknowledgements for the post
err = ss.PostAcknowledgement().DeleteAllForPost(post.Id)
require.NoError(t, err)
// Verify all acknowledgements (including soft-deleted) are permanently deleted
acksWithDeleted, err := ss.PostAcknowledgement().GetForPostSince(post.Id, 0, "", true)
require.NoError(t, err)
require.Empty(t, acksWithDeleted, "All acknowledgements including soft-deleted should be permanently deleted")
})
}

View file

@ -67,6 +67,7 @@ func TestPostStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("SetPostReminder", func(t *testing.T) { testSetPostReminder(t, rctx, ss, s) })
t.Run("GetPostReminders", func(t *testing.T) { testGetPostReminders(t, rctx, ss, s) })
t.Run("GetPostReminderMetadata", func(t *testing.T) { testGetPostReminderMetadata(t, rctx, ss, s) })
t.Run("DeleteAllPostRemindersForPost", func(t *testing.T) { testDeleteAllPostRemindersForPost(t, rctx, ss, s) })
t.Run("GetNthRecentPostTime", func(t *testing.T) { testGetNthRecentPostTime(t, rctx, ss) })
t.Run("GetEditHistoryForPost", func(t *testing.T) { testGetEditHistoryForPost(t, rctx, ss) })
t.Run("RestoreContentFlaggedPost", func(t *testing.T) { testRestoreContentFlaggedPost(t, rctx, ss) })
@ -6105,3 +6106,192 @@ func testRestoreContentFlaggedPost(t *testing.T, rctx request.CTX, ss store.Stor
require.Equal(t, int64(1), thread.ReplyCount)
})
}
func testDeleteAllPostRemindersForPost(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("should permanently delete all reminders for a post", func(t *testing.T) {
userID1 := NewTestID()
userID2 := NewTestID()
userID3 := NewTestID()
p1 := &model.Post{
UserId: userID1,
ChannelId: NewTestID(),
Message: "test post",
Type: model.PostTypeDefault,
}
p1, err := ss.Post().Save(rctx, p1)
require.NoError(t, err)
// Create multiple reminders for the same post from different users
reminder1 := &model.PostReminder{
TargetTime: 1000,
PostId: p1.Id,
UserId: userID1,
}
require.NoError(t, ss.Post().SetPostReminder(reminder1))
reminder2 := &model.PostReminder{
TargetTime: 2000,
PostId: p1.Id,
UserId: userID2,
}
require.NoError(t, ss.Post().SetPostReminder(reminder2))
reminder3 := &model.PostReminder{
TargetTime: 3000,
PostId: p1.Id,
UserId: userID3,
}
require.NoError(t, ss.Post().SetPostReminder(reminder3))
// Verify reminders were created
var count int
err = s.GetMaster().Get(&count, `SELECT COUNT(*) FROM PostReminders WHERE PostId=?`, p1.Id)
require.NoError(t, err)
require.Equal(t, 3, count)
// Delete all reminders for the post
err = ss.Post().DeleteAllPostRemindersForPost(p1.Id)
require.NoError(t, err)
// Verify all reminders were deleted
err = s.GetMaster().Get(&count, `SELECT COUNT(*) FROM PostReminders WHERE PostId=?`, p1.Id)
require.NoError(t, err)
require.Equal(t, 0, count)
})
t.Run("should only delete reminders for the specified post", func(t *testing.T) {
userID1 := NewTestID()
userID2 := NewTestID()
// Create two posts
p1 := &model.Post{
UserId: userID1,
ChannelId: NewTestID(),
Message: "test post 1",
Type: model.PostTypeDefault,
}
p1, err := ss.Post().Save(rctx, p1)
require.NoError(t, err)
p2 := &model.Post{
UserId: userID1,
ChannelId: NewTestID(),
Message: "test post 2",
Type: model.PostTypeDefault,
}
p2, err = ss.Post().Save(rctx, p2)
require.NoError(t, err)
// Create reminders for both posts
reminder1 := &model.PostReminder{
TargetTime: 1000,
PostId: p1.Id,
UserId: userID1,
}
require.NoError(t, ss.Post().SetPostReminder(reminder1))
reminder2 := &model.PostReminder{
TargetTime: 2000,
PostId: p1.Id,
UserId: userID2,
}
require.NoError(t, ss.Post().SetPostReminder(reminder2))
reminder3 := &model.PostReminder{
TargetTime: 3000,
PostId: p2.Id,
UserId: userID1,
}
require.NoError(t, ss.Post().SetPostReminder(reminder3))
reminder4 := &model.PostReminder{
TargetTime: 4000,
PostId: p2.Id,
UserId: userID2,
}
require.NoError(t, ss.Post().SetPostReminder(reminder4))
// Delete all reminders for post1 only
err = ss.Post().DeleteAllPostRemindersForPost(p1.Id)
require.NoError(t, err)
// Verify post1 reminders are deleted
var count int
err = s.GetMaster().Get(&count, `SELECT COUNT(*) FROM PostReminders WHERE PostId=?`, p1.Id)
require.NoError(t, err)
require.Equal(t, 0, count)
// Verify post2 reminders are still present
err = s.GetMaster().Get(&count, `SELECT COUNT(*) FROM PostReminders WHERE PostId=?`, p2.Id)
require.NoError(t, err)
require.Equal(t, 2, count)
})
t.Run("should not error when deleting from post with no reminders", func(t *testing.T) {
userID := NewTestID()
p1 := &model.Post{
UserId: userID,
ChannelId: NewTestID(),
Message: "test post",
Type: model.PostTypeDefault,
}
p1, err := ss.Post().Save(rctx, p1)
require.NoError(t, err)
// Verify no reminders exist
var count int
err = s.GetMaster().Get(&count, `SELECT COUNT(*) FROM PostReminders WHERE PostId=?`, p1.Id)
require.NoError(t, err)
require.Equal(t, 0, count)
// Delete should not error
err = ss.Post().DeleteAllPostRemindersForPost(p1.Id)
require.NoError(t, err)
})
t.Run("should not error when deleting from non-existent post", func(t *testing.T) {
nonExistentPostId := model.NewId()
// Delete should not error even for non-existent post
err := ss.Post().DeleteAllPostRemindersForPost(nonExistentPostId)
require.NoError(t, err)
})
t.Run("should handle deleting single reminder for post", func(t *testing.T) {
userID := NewTestID()
p1 := &model.Post{
UserId: userID,
ChannelId: NewTestID(),
Message: "test post",
Type: model.PostTypeDefault,
}
p1, err := ss.Post().Save(rctx, p1)
require.NoError(t, err)
// Create a single reminder
reminder := &model.PostReminder{
TargetTime: 1000,
PostId: p1.Id,
UserId: userID,
}
require.NoError(t, ss.Post().SetPostReminder(reminder))
// Verify reminder was created
var count int
err = s.GetMaster().Get(&count, `SELECT COUNT(*) FROM PostReminders WHERE PostId=?`, p1.Id)
require.NoError(t, err)
require.Equal(t, 1, count)
// Delete all reminders for the post
err = ss.Post().DeleteAllPostRemindersForPost(p1.Id)
require.NoError(t, err)
// Verify reminder was deleted
err = s.GetMaster().Get(&count, `SELECT COUNT(*) FROM PostReminders WHERE PostId=?`, p1.Id)
require.NoError(t, err)
require.Equal(t, 0, count)
})
}

View file

@ -3757,6 +3757,17 @@ func (c *Client4) GetFlaggingConfiguration(ctx context.Context) (*ContentFlaggin
return DecodeJSONFromResponse[*ContentFlaggingReportingConfig](r)
}
func (c *Client4) GetFlaggingConfigurationForTeam(ctx context.Context, teamId string) (*ContentFlaggingReportingConfig, *Response, error) {
values := url.Values{}
values.Set("team_id", teamId)
r, err := c.doAPIGetWithQuery(ctx, c.contentFlaggingRoute().Join("flag", "config"), values, "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return DecodeJSONFromResponse[*ContentFlaggingReportingConfig](r)
}
func (c *Client4) GetTeamPostFlaggingFeatureStatus(ctx context.Context, teamId string) (map[string]bool, *Response, error) {
r, err := c.doAPIGet(ctx, c.contentFlaggingRoute().Join("team", teamId, "status"), "")
if err != nil {
@ -3805,6 +3816,26 @@ func (c *Client4) SearchContentFlaggingReviewers(ctx context.Context, teamID, te
return DecodeJSONFromResponse[[]*User](r)
}
func (c *Client4) RemoveFlaggedPost(ctx context.Context, postId string, actionRequest *FlagContentActionRequest) (*Response, error) {
r, err := c.doAPIPutJSON(ctx, c.contentFlaggingRoute().Join("post", postId, "remove"), actionRequest)
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
func (c *Client4) KeepFlaggedPost(ctx context.Context, postId string, actionRequest *FlagContentActionRequest) (*Response, error) {
r, err := c.doAPIPutJSON(ctx, c.contentFlaggingRoute().Join("post", postId, "keep"), actionRequest)
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
// SearchFiles returns any posts with matching terms string.
func (c *Client4) SearchFiles(ctx context.Context, teamId string, terms string, isOrSearch bool) (*FileInfoList, *Response, error) {
params := SearchParameter{

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import DataSpillageFooter from './data_spillage_footer';
jest.mock('actions/views/rhs', () => ({
selectPostFromRightHandSideSearch: jest.fn((post) => ({
type: 'SELECT_POST_FROM_RHS_SEARCH',
payload: post,
})),
}));
const mockedSelectPostFromRightHandSideSearch = require('actions/views/rhs').selectPostFromRightHandSideSearch as jest.MockedFunction<any>;
describe('DataSpillageFooter', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should render footer with view details button', () => {
const post = TestHelper.getPostMock();
renderWithContext(
<DataSpillageFooter post={post}/>,
);
expect(screen.getByTestId('data-spillage-footer')).toBeVisible();
expect(screen.getByTestId('data-spillage-action-view-details')).toBeVisible();
expect(screen.getByText('View details')).toBeVisible();
});
test('should dispatch selectPostFromRightHandSideSearch when button is clicked', async () => {
const post = TestHelper.getPostMock({
id: 'test_post_id',
message: 'test message',
});
renderWithContext(
<DataSpillageFooter post={post}/>,
);
const viewDetailsButton = screen.getByTestId('data-spillage-action-view-details');
await userEvent.click(viewDetailsButton);
expect(mockedSelectPostFromRightHandSideSearch).toHaveBeenCalledTimes(1);
expect(mockedSelectPostFromRightHandSideSearch).toHaveBeenCalledWith(post);
});
});

View file

@ -0,0 +1,300 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import {Client4} from 'mattermost-redux/client';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import KeepRemoveFlaggedMessageConfirmationModal from './remove_flagged_message_confirmation_modal';
jest.mock('components/common/hooks/useUser');
jest.mock('components/common/hooks/useChannel');
jest.mock('components/common/hooks/useContentFlaggingFields');
const mockedUseUser = require('components/common/hooks/useUser').useUser as jest.MockedFunction<any>;
const mockedUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction<any>;
const mockedUseContentFlaggingConfig = require('components/common/hooks/useContentFlaggingFields').useContentFlaggingConfig as jest.MockedFunction<any>;
describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
const flaggedPostAuthor = TestHelper.getUserMock({
id: 'flagged_post_author_id',
username: 'flagged_post_author',
});
const reportingUser = TestHelper.getUserMock({
id: 'reporting_user_id',
username: 'reporting_user',
});
const flaggedPostChannel = TestHelper.getChannelMock({
id: 'flagged_post_channel_id',
display_name: 'Flagged Post Channel',
team_id: 'team_id',
});
const flaggedPost = TestHelper.getPostMock({
id: 'flagged_post_id',
message: 'Flagged message content',
channel_id: flaggedPostChannel.id,
user_id: flaggedPostAuthor.id,
});
const defaultContentFlaggingConfig = {
reviewer_comment_required: false,
notify_reporter_on_removal: false,
notify_reporter_on_dismissal: false,
};
const onExited = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockedUseUser.mockReturnValue(flaggedPostAuthor);
mockedUseChannel.mockReturnValue(flaggedPostChannel);
mockedUseContentFlaggingConfig.mockReturnValue(
defaultContentFlaggingConfig,
);
Client4.removeFlaggedPost = jest.fn().mockResolvedValue({});
Client4.keepFlaggedPost = jest.fn().mockResolvedValue({});
console.error = jest.fn();
});
describe('remove action', () => {
test('should render modal with remove action content', () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
expect(screen.getByTestId('keep-remove-flagged-message-confirmation-modal')).toBeVisible();
expect(screen.getByRole('heading', {name: 'Remove message from channel'})).toBeVisible();
expect(screen.getByRole('button', {name: 'Remove message'})).toBeVisible();
});
test('should show notification subtext when notify_reporter_on_removal is true', () => {
mockedUseContentFlaggingConfig.mockReturnValue({
...defaultContentFlaggingConfig,
notify_reporter_on_removal: true,
});
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const subtext = screen.getByTestId('keep-remove-flagged-message-subtext');
expect(subtext).toBeVisible();
expect(subtext).toHaveTextContent(/a notification will be sent to the reporter/);
});
test('should show no notification subtext when notify_reporter_on_removal is false', () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const subtext = screen.getByTestId('keep-remove-flagged-message-subtext');
expect(subtext).toBeVisible();
expect(subtext).toHaveTextContent(/the message will be removed from the channel. This action cannot be reverted./);
});
test('should call Client4.removeFlaggedPost on confirm', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Remove message'});
await userEvent.click(confirmButton);
await waitFor(() => {
expect(Client4.removeFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, '');
});
expect(onExited).toHaveBeenCalled();
});
});
describe('keep action', () => {
test('should render modal with keep action content', () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='keep'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
expect(screen.getByTestId('keep-remove-flagged-message-confirmation-modal')).toBeVisible();
expect(screen.getByRole('button', {name: 'Keep message'})).toBeVisible();
});
test('should show notification subtext when notify_reporter_on_dismissal is true', () => {
mockedUseContentFlaggingConfig.mockReturnValue({
...defaultContentFlaggingConfig,
notify_reporter_on_dismissal: true,
});
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='keep'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const subtext = screen.getByTestId('keep-remove-flagged-message-subtext');
expect(subtext).toBeVisible();
expect(subtext).toHaveTextContent(/a notification will be sent to the reporter/);
});
test('should show no notification subtext when notify_reporter_on_dismissal is false', () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='keep'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const subtext = screen.getByTestId('keep-remove-flagged-message-subtext');
expect(subtext).toBeVisible();
expect(subtext).toHaveTextContent(/the message will be visible to all channel members./);
});
test('should call Client4.keepFlaggedPost on confirm', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='keep'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Keep message'});
await userEvent.click(confirmButton);
await waitFor(() => {
expect(Client4.keepFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, '');
});
expect(onExited).toHaveBeenCalled();
});
});
describe('comment section', () => {
test('should show optional comment label when reviewer_comment_required is false', () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const commentTitle = screen.getByTestId('keep-remove-flagged-message-comment-title');
expect(commentTitle).toBeVisible();
expect(commentTitle).toHaveTextContent('Comment (optional)');
});
test('should show required comment label when reviewer_comment_required is true', () => {
mockedUseContentFlaggingConfig.mockReturnValue({
...defaultContentFlaggingConfig,
reviewer_comment_required: true,
});
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const commentTitle = screen.getByTestId('keep-remove-flagged-message-comment-title');
expect(commentTitle).toBeVisible();
expect(commentTitle).toHaveTextContent('Comment (required)');
});
test('should show validation error when comment is required but empty on confirm', async () => {
mockedUseContentFlaggingConfig.mockReturnValue({
...defaultContentFlaggingConfig,
reviewer_comment_required: true,
});
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Remove message'});
await userEvent.click(confirmButton);
await waitFor(() => {
expect(screen.getByText('Please add a comment.')).toBeVisible();
});
expect(Client4.removeFlaggedPost).not.toHaveBeenCalled();
expect(onExited).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
test('should show request error when API call fails', async () => {
const errorMessage = 'Failed to remove flagged post';
Client4.removeFlaggedPost = jest.fn().mockRejectedValue({message: errorMessage});
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Remove message'});
await userEvent.click(confirmButton);
await waitFor(() => {
const errorElement = screen.getByTestId(
'keep-remove-flagged-message-request-error',
);
expect(errorElement).toBeVisible();
expect(errorElement).toHaveTextContent(errorMessage);
});
expect(onExited).not.toHaveBeenCalled();
});
});
});

View file

@ -182,16 +182,20 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi
isConfirmDisabled={submitting}
>
<div className='body'>
<div className='section'>
<div
className='section'
data-testid='keep-remove-flagged-message-body'
>
{body}
<br/>
<br/>
{subtext}
<span data-testid='keep-remove-flagged-message-subtext'>{subtext}</span>
</div>
<div className='section comment_section'>
<div
className='section_title'
data-testid='keep-remove-flagged-message-comment-title'
>
{contentFlaggingConfig?.reviewer_comment_required ? requiredCommentSectionTitle : optionalCommentSectionTitle}
</div>
@ -212,7 +216,10 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi
/>
</div>
{requestError &&
<div className='request_error'>
<div
className='request_error'
data-testid='keep-remove-flagged-message-request-error'
>
<i className='icon icon-alert-outline'/>
<span>{requestError}</span>
</div>