From b96f7c1a8da95e7caff2d948055c8dd02161f54a Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Mon, 23 Feb 2026 06:15:40 +0530 Subject: [PATCH] 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 --- server/channels/api4/content_flagging.go | 41 +- server/channels/api4/content_flagging_test.go | 874 +++++++++++------- server/channels/app/content_flagging_test.go | 528 ++++++++++- .../storetest/post_acknowledgements_store.go | 161 ++++ server/channels/store/storetest/post_store.go | 190 ++++ server/public/model/client4.go | 31 + .../data_spillage_footer.test.tsx | 55 ++ ...lagged_message_confirmation_modal.test.tsx | 300 ++++++ ...ove_flagged_message_confirmation_modal.tsx | 13 +- 9 files changed, 1855 insertions(+), 338 deletions(-) create mode 100644 webapp/channels/src/components/post_view/data_spillage_report/data_spillage_footer/data_spillage_footer.test.tsx create mode 100644 webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.test.tsx diff --git a/server/channels/api4/content_flagging.go b/server/channels/api4/content_flagging.go index 47598fef666..e742394a4ad 100644 --- a/server/channels/api4/content_flagging.go +++ b/server/channels/api4/content_flagging.go @@ -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 } diff --git a/server/channels/api4/content_flagging_test.go b/server/channels/api4/content_flagging_test.go index 2fccea02a5d..ddf35bc46f4 100644 --- a/server/channels/api4/content_flagging_test.go +++ b/server/channels/api4/content_flagging_test.go @@ -32,13 +32,138 @@ func setBasicCommonReviewerConfig(th *TestHelper) *model.AppError { return th.App.SaveContentFlaggingConfig(config) } -func TestGetFlaggingConfiguration(t *testing.T) { - th := Setup(t) +func setNonReviewerConfig(th *TestHelper) *model.AppError { + config := model.ContentFlaggingSettingsRequest{ + ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ + EnableContentFlagging: model.NewPointer(true), + }, + ReviewerSettings: &model.ReviewSettingsRequest{ + ReviewerSettings: model.ReviewerSettings{ + CommonReviewers: model.NewPointer(false), + }, + ReviewerIDsSettings: model.ReviewerIDsSettings{ + TeamReviewersSetting: map[string]*model.TeamReviewerSetting{ + th.BasicTeam.Id: { + Enabled: model.NewPointer(true), + ReviewerIds: []string{}, + }, + }, + }, + }, + } + config.SetDefaults() + return th.App.SaveContentFlaggingConfig(config) +} - client := th.Client +func setBasicTeamReviewerConfig(th *TestHelper, extraReviewerIds ...string) *model.AppError { + ids := []string{th.BasicUser.Id} + ids = append(ids, extraReviewerIds...) + config := model.ContentFlaggingSettingsRequest{ + ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ + EnableContentFlagging: model.NewPointer(true), + }, + ReviewerSettings: &model.ReviewSettingsRequest{ + ReviewerSettings: model.ReviewerSettings{ + CommonReviewers: model.NewPointer(false), + }, + ReviewerIDsSettings: model.ReviewerIDsSettings{ + TeamReviewersSetting: map[string]*model.TeamReviewerSetting{ + th.BasicTeam.Id: { + Enabled: model.NewPointer(true), + ReviewerIds: ids, + }, + }, + }, + }, + } + config.SetDefaults() + return th.App.SaveContentFlaggingConfig(config) +} - t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) +func setCommonReviewerWithRequiredCommentConfig(th *TestHelper) *model.AppError { + config := model.ContentFlaggingSettingsRequest{ + ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ + EnableContentFlagging: model.NewPointer(true), + AdditionalSettings: &model.AdditionalContentFlaggingSettings{ + ReviewerCommentRequired: model.NewPointer(true), + }, + }, + ReviewerSettings: &model.ReviewSettingsRequest{ + ReviewerSettings: model.ReviewerSettings{ + CommonReviewers: model.NewPointer(true), + }, + ReviewerIDsSettings: model.ReviewerIDsSettings{ + CommonReviewerIds: []string{th.BasicUser.Id}, + }, + }, + } + config.SetDefaults() + return th.App.SaveContentFlaggingConfig(config) +} + +func flagPostViaAPI(t *testing.T, client *model.Client4, postId string) { + t.Helper() + flagRequest := &model.FlagContentRequest{ + Reason: "Sensitive data", + Comment: "This is sensitive content", + } + resp, err := client.FlagPostForContentReview(context.Background(), postId, flagRequest) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func uploadFileAndCreatePost(t *testing.T, th *TestHelper, client *model.Client4) (*model.Post, *model.FileInfo) { + t.Helper() + data, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + + fileResponse, _, err := client.UploadFile(context.Background(), data, th.BasicChannel.Id, "test.png") + require.NoError(t, err) + require.Equal(t, 1, len(fileResponse.FileInfos)) + fileInfo := fileResponse.FileInfos[0] + + post := th.CreatePostInChannelWithFiles(t, th.BasicChannel, fileInfo) + return post, fileInfo +} + +func TestRequireContentFlaggingEnabled(t *testing.T) { + th := Setup(t).InitBasic(t) + + t.Run("Should set error when license is not valid", func(t *testing.T) { + th.RemoveLicense(t) + c := &Context{ + App: th.App, + Logger: th.App.Log(), + } + + requireContentFlaggingEnabled(c) + require.NotNil(t, c.Err) + require.Equal(t, "api.content_flagging.error.license", c.Err.Id) + require.Equal(t, http.StatusNotImplemented, c.Err.StatusCode) + }) + + t.Run("Should set error when feature is disabled in config", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false) + config.ContentFlaggingSettings.SetDefaults() + }) + + c := &Context{ + App: th.App, + Logger: th.App.Log(), + } + + requireContentFlaggingEnabled(c) + require.NotNil(t, c.Err) + require.Equal(t, "api.content_flagging.error.disabled", c.Err.Id) + require.Equal(t, http.StatusNotImplemented, c.Err.StatusCode) + }) + + t.Run("Should not set error when license is valid and feature is enabled", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) th.App.UpdateConfig(func(config *model.Config) { @@ -46,11 +171,20 @@ func TestGetFlaggingConfiguration(t *testing.T) { config.ContentFlaggingSettings.SetDefaults() }) - status, resp, err := client.GetFlaggingConfiguration(context.Background()) - require.Error(t, err) - require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - require.Nil(t, status) + c := &Context{ + App: th.App, + Logger: th.App.Log(), + } + + requireContentFlaggingEnabled(c) + require.Nil(t, c.Err) }) +} + +func TestGetFlaggingConfiguration(t *testing.T) { + th := Setup(t).InitBasic(t) + + client := th.Client t.Run("Should return 501 when feature is disabled", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) @@ -66,6 +200,78 @@ func TestGetFlaggingConfiguration(t *testing.T) { require.Equal(t, http.StatusNotImplemented, resp.StatusCode) require.Nil(t, status) }) + + t.Run("Should successfully return configuration without team_id for any authenticated user", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) + config.ContentFlaggingSettings.SetDefaults() + }) + + config, resp, err := client.GetFlaggingConfiguration(context.Background()) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, config) + require.NotEmpty(t, config.Reasons) + require.NotNil(t, config.ReporterCommentRequired) + require.NotNil(t, config.ReviewerCommentRequired) + // Reviewer-only fields should be nil when not requesting as a reviewer + require.Nil(t, config.NotifyReporterOnRemoval) + require.Nil(t, config.NotifyReporterOnDismissal) + }) + + t.Run("Should return 403 when team_id is provided but user is not a reviewer", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setNonReviewerConfig(th) + require.Nil(t, appErr) + + flagConfig, resp, err := client.GetFlaggingConfigurationForTeam(context.Background(), th.BasicTeam.Id) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + require.Nil(t, flagConfig) + }) + + t.Run("Should successfully return configuration with reviewer fields when user is a reviewer", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + config, resp, err := client.GetFlaggingConfigurationForTeam(context.Background(), th.BasicTeam.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, config) + require.NotEmpty(t, config.Reasons) + require.NotNil(t, config.ReporterCommentRequired) + require.NotNil(t, config.ReviewerCommentRequired) + // Reviewer-only fields should be present when requesting as a reviewer + require.NotNil(t, config.NotifyReporterOnRemoval) + require.NotNil(t, config.NotifyReporterOnDismissal) + }) + + t.Run("Should successfully return configuration with reviewer fields when user is a team reviewer", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicTeamReviewerConfig(th) + require.Nil(t, appErr) + + flagConfig, resp, err := client.GetFlaggingConfigurationForTeam(context.Background(), th.BasicTeam.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, flagConfig) + require.NotEmpty(t, flagConfig.Reasons) + require.NotNil(t, flagConfig.ReporterCommentRequired) + require.NotNil(t, flagConfig.ReviewerCommentRequired) + // Reviewer-only fields should be present when requesting as a team reviewer + require.NotNil(t, flagConfig.NotifyReporterOnRemoval) + require.NotNil(t, flagConfig.NotifyReporterOnDismissal) + }) } func TestSaveContentFlaggingSettings(t *testing.T) { @@ -190,20 +396,6 @@ func TestGetPostPropertyValues(t *testing.T) { client := th.Client - t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - post := th.CreatePost(t) - propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id) - require.Error(t, err) - require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - require.Nil(t, propertyValues) - }) - t.Run("Should return 501 when feature is disabled", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) th.App.UpdateConfig(func(config *model.Config) { @@ -218,19 +410,6 @@ func TestGetPostPropertyValues(t *testing.T) { require.Nil(t, propertyValues) }) - t.Run("Should return 404 when post does not exist", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), model.NewId()) - require.Error(t, err) - require.Equal(t, http.StatusNotFound, resp.StatusCode) - require.Nil(t, propertyValues) - }) - t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) th.App.UpdateConfig(func(config *model.Config) { @@ -251,12 +430,7 @@ func TestGetPostPropertyValues(t *testing.T) { require.Nil(t, appErr) post := th.CreatePost(t) - response, err := client.FlagPostForContentReview(context.Background(), post.Id, &model.FlagContentRequest{ - Reason: "Sensitive data", - Comment: "This is sensitive content", - }) - require.NoError(t, err) - require.Equal(t, http.StatusOK, response.StatusCode) + flagPostViaAPI(t, client, post.Id) // Now get the property values propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id) @@ -272,20 +446,6 @@ func TestGetFlaggedPost(t *testing.T) { client := th.Client - t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - post := th.CreatePost(t) - flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id) - require.Error(t, err) - require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - require.Nil(t, flaggedPost) - }) - t.Run("Should return 501 when feature is disabled", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) th.App.UpdateConfig(func(config *model.Config) { @@ -300,42 +460,10 @@ func TestGetFlaggedPost(t *testing.T) { require.Nil(t, flaggedPost) }) - t.Run("Should return 404 when post does not exist", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), model.NewId()) - require.Error(t, err) - require.Equal(t, http.StatusNotFound, resp.StatusCode) - require.Nil(t, flaggedPost) - }) - t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) - config := model.ContentFlaggingSettingsRequest{ - ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ - EnableContentFlagging: model.NewPointer(true), - }, - ReviewerSettings: &model.ReviewSettingsRequest{ - ReviewerSettings: model.ReviewerSettings{ - CommonReviewers: model.NewPointer(false), - }, - ReviewerIDsSettings: model.ReviewerIDsSettings{ - TeamReviewersSetting: map[string]*model.TeamReviewerSetting{ - th.BasicTeam.Id: { - Enabled: model.NewPointer(true), - ReviewerIds: []string{}, // Empty list - user is not a reviewer - }, - }, - }, - }, - } - config.SetDefaults() - appErr := th.App.SaveContentFlaggingConfig(config) + appErr := setNonReviewerConfig(th) require.Nil(t, appErr) post := th.CreatePost(t) @@ -365,15 +493,7 @@ func TestGetFlaggedPost(t *testing.T) { require.Nil(t, appErr) post := th.CreatePost(t) - - // First flag the post - flagRequest := &model.FlagContentRequest{ - Reason: "Sensitive data", - Comment: "This is sensitive content", - } - resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) + flagPostViaAPI(t, client, post.Id) // Now get the flagged post flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id) @@ -389,24 +509,8 @@ func TestGetFlaggedPost(t *testing.T) { appErr := setBasicCommonReviewerConfig(th) require.Nil(t, appErr) - data, err2 := testutils.ReadTestFile("test.png") - require.NoError(t, err2) - - fileResponse, _, err := client.UploadFile(context.Background(), data, th.BasicChannel.Id, "test.png") - require.NoError(t, err) - require.Equal(t, 1, len(fileResponse.FileInfos)) - fileInfo := fileResponse.FileInfos[0] - - post := th.CreatePostInChannelWithFiles(t, th.BasicChannel, fileInfo) - - // First flag the post - flagRequest := &model.FlagContentRequest{ - Reason: "Sensitive data", - Comment: "This is sensitive content", - } - resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) + post, fileInfo := uploadFileAndCreatePost(t, th, client) + flagPostViaAPI(t, client, post.Id) flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id) require.NoError(t, err) @@ -425,26 +529,6 @@ func TestFlagPost(t *testing.T) { client := th.Client - t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) - defer th.RemoveLicense(t) - - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - post := th.CreatePost(t) - flagRequest := &model.FlagContentRequest{ - Reason: "spam", - Comment: "This is spam content", - } - - resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest) - require.Error(t, err) - require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - }) - t.Run("Should return 501 when feature is disabled", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) @@ -465,25 +549,6 @@ func TestFlagPost(t *testing.T) { require.Equal(t, http.StatusNotImplemented, resp.StatusCode) }) - t.Run("Should return 404 when post does not exist", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) - defer th.RemoveLicense(t) - - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - flagRequest := &model.FlagContentRequest{ - Reason: "spam", - Comment: "This is spam content", - } - - resp, err := client.FlagPostForContentReview(context.Background(), model.NewId(), flagRequest) - require.Error(t, err) - require.Equal(t, http.StatusNotFound, resp.StatusCode) - }) - t.Run("Should return 403 when user does not have permission to view post", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) @@ -596,21 +661,6 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) { client := th.Client - t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) - defer th.RemoveLicense(t) - - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - status, resp, err := client.GetTeamPostFlaggingFeatureStatus(context.Background(), model.NewId()) - require.Error(t, err) - require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - require.Nil(t, status) - }) - t.Run("Should return 501 when feature is disabled", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) @@ -674,21 +724,6 @@ func TestSearchReviewers(t *testing.T) { client := th.Client - t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) - defer th.RemoveLicense(t) - - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test") - require.Error(t, err) - require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - require.Nil(t, reviewers) - }) - t.Run("Should return 501 when feature is disabled", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) @@ -708,26 +743,7 @@ func TestSearchReviewers(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) - config := model.ContentFlaggingSettingsRequest{ - ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ - EnableContentFlagging: model.NewPointer(true), - }, - ReviewerSettings: &model.ReviewSettingsRequest{ - ReviewerSettings: model.ReviewerSettings{ - CommonReviewers: model.NewPointer(false), - }, - ReviewerIDsSettings: model.ReviewerIDsSettings{ - TeamReviewersSetting: map[string]*model.TeamReviewerSetting{ - th.BasicTeam.Id: { - Enabled: model.NewPointer(true), - ReviewerIds: []string{}, // Empty list - user is not a reviewer - }, - }, - }, - }, - } - config.SetDefaults() - appErr := th.App.SaveContentFlaggingConfig(config) + appErr := setNonReviewerConfig(th) require.Nil(t, appErr) reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test") @@ -753,26 +769,7 @@ func TestSearchReviewers(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) - config := model.ContentFlaggingSettingsRequest{ - ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ - EnableContentFlagging: model.NewPointer(true), - }, - ReviewerSettings: &model.ReviewSettingsRequest{ - ReviewerSettings: model.ReviewerSettings{ - CommonReviewers: model.NewPointer(false), - }, - ReviewerIDsSettings: model.ReviewerIDsSettings{ - TeamReviewersSetting: map[string]*model.TeamReviewerSetting{ - th.BasicTeam.Id: { - Enabled: model.NewPointer(true), - ReviewerIds: []string{th.BasicUser.Id}, - }, - }, - }, - }, - } - config.SetDefaults() - appErr := th.App.SaveContentFlaggingConfig(config) + appErr := setBasicTeamReviewerConfig(th) require.Nil(t, appErr) reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "basic") @@ -787,21 +784,6 @@ func TestAssignContentFlaggingReviewer(t *testing.T) { client := th.Client - t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) - defer th.RemoveLicense(t) - - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - post := th.CreatePost(t) - resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser.Id) - require.Error(t, err) - require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - }) - t.Run("Should return 501 when feature is disabled", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) @@ -817,20 +799,6 @@ func TestAssignContentFlaggingReviewer(t *testing.T) { require.Equal(t, http.StatusNotImplemented, resp.StatusCode) }) - t.Run("Should return 404 when post does not exist", func(t *testing.T) { - th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) - defer th.RemoveLicense(t) - - th.App.UpdateConfig(func(config *model.Config) { - config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) - config.ContentFlaggingSettings.SetDefaults() - }) - - resp, err := client.AssignContentFlaggingReviewer(context.Background(), model.NewId(), th.BasicUser.Id) - require.Error(t, err) - require.Equal(t, http.StatusNotFound, resp.StatusCode) - }) - t.Run("Should return 400 when user ID is invalid", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) @@ -848,26 +816,7 @@ func TestAssignContentFlaggingReviewer(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) defer th.RemoveLicense(t) - config := model.ContentFlaggingSettingsRequest{ - ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ - EnableContentFlagging: model.NewPointer(true), - }, - ReviewerSettings: &model.ReviewSettingsRequest{ - ReviewerSettings: model.ReviewerSettings{ - CommonReviewers: model.NewPointer(false), - }, - ReviewerIDsSettings: model.ReviewerIDsSettings{ - TeamReviewersSetting: map[string]*model.TeamReviewerSetting{ - th.BasicTeam.Id: { - Enabled: model.NewPointer(true), - ReviewerIds: []string{}, // Empty list - user is not a reviewer - }, - }, - }, - }, - } - config.SetDefaults() - appErr := th.App.SaveContentFlaggingConfig(config) + appErr := setNonReviewerConfig(th) require.Nil(t, appErr) post := th.CreatePost(t) @@ -916,6 +865,10 @@ func TestAssignContentFlaggingReviewer(t *testing.T) { reviewerUser := th.CreateUser(t) th.LinkUserToTeam(t, reviewerUser, th.BasicTeam) + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + // Also add reviewerUser as a common reviewer config := model.ContentFlaggingSettingsRequest{ ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ EnableContentFlagging: model.NewPointer(true), @@ -930,19 +883,11 @@ func TestAssignContentFlaggingReviewer(t *testing.T) { }, } config.SetDefaults() - appErr := th.App.SaveContentFlaggingConfig(config) + appErr = th.App.SaveContentFlaggingConfig(config) require.Nil(t, appErr) post := th.CreatePost(t) - - // First flag the post so it can be assigned - flagRequest := &model.FlagContentRequest{ - Reason: "Sensitive data", - Comment: "This is sensitive content", - } - flagResp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest) - require.NoError(t, err) - require.Equal(t, http.StatusOK, flagResp.StatusCode) + flagPostViaAPI(t, client, post.Id) // Now assign the reviewer resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, reviewerUser.Id) @@ -958,38 +903,11 @@ func TestAssignContentFlaggingReviewer(t *testing.T) { reviewerUser := th.CreateUser(t) th.LinkUserToTeam(t, reviewerUser, th.BasicTeam) - config := model.ContentFlaggingSettingsRequest{ - ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ - EnableContentFlagging: model.NewPointer(true), - }, - ReviewerSettings: &model.ReviewSettingsRequest{ - ReviewerSettings: model.ReviewerSettings{ - CommonReviewers: model.NewPointer(false), - }, - ReviewerIDsSettings: model.ReviewerIDsSettings{ - TeamReviewersSetting: map[string]*model.TeamReviewerSetting{ - th.BasicTeam.Id: { - Enabled: model.NewPointer(true), - ReviewerIds: []string{th.BasicUser.Id, reviewerUser.Id}, - }, - }, - }, - }, - } - config.SetDefaults() - appErr := th.App.SaveContentFlaggingConfig(config) + appErr := setBasicTeamReviewerConfig(th, reviewerUser.Id) require.Nil(t, appErr) post := th.CreatePost(t) - - // First flag the post so it can be assigned - flagRequest := &model.FlagContentRequest{ - Reason: "Sensitive data", - Comment: "This is sensitive content", - } - flagResp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest) - require.NoError(t, err) - require.Equal(t, http.StatusOK, flagResp.StatusCode) + flagPostViaAPI(t, client, post.Id) // Now assign the reviewer resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, reviewerUser.Id) @@ -997,3 +915,333 @@ func TestAssignContentFlaggingReviewer(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) }) } + +func TestRemoveFlaggedPost(t *testing.T) { + th := Setup(t).InitBasic(t) + + client := th.Client + + t.Run("Should return 501 when feature is disabled", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false) + config.ContentFlaggingSettings.SetDefaults() + }) + + post := th.CreatePost(t) + actionRequest := &model.FlagContentActionRequest{ + Comment: "Removing this post", + } + + resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, actionRequest) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) + + t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setNonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + actionRequest := &model.FlagContentActionRequest{ + Comment: "Removing this post", + } + + resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, actionRequest) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("Should return 400 when comment is required but not provided", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setCommonReviewerWithRequiredCommentConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + // Try to remove without comment + actionRequest := &model.FlagContentActionRequest{ + Comment: "", // Empty comment when required + } + + resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, actionRequest) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Should successfully remove flagged post when all conditions are met", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + // Now remove the flagged post + actionRequest := &model.FlagContentActionRequest{ + Comment: "Removing this post due to policy violation", + } + + resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, actionRequest) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the post was deleted + _, resp, err = client.GetPost(context.Background(), post.Id, "") + require.Error(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Should successfully remove flagged post when user is team reviewer", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicTeamReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + // Now remove the flagged post + actionRequest := &model.FlagContentActionRequest{ + Comment: "Removing this post due to policy violation", + } + + resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, actionRequest) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("Should remove file attachments and edit history when removing flagged post", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post, fileInfo := uploadFileAndCreatePost(t, th, client) + + // Verify file info exists for the post + fileInfos, err2 := th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, false, false) + require.NoError(t, err2) + require.Len(t, fileInfos, 1) + require.Equal(t, fileInfo.Id, fileInfos[0].Id) + + // Update the post to create edit history + post.Message = "Updated message to create edit history" + updatedPost, _, err := client.UpdatePost(context.Background(), post.Id, post) + require.NoError(t, err) + require.NotNil(t, updatedPost) + require.Equal(t, "Updated message to create edit history", updatedPost.Message) + + // Verify edit history exists + editHistory, appErr := th.App.GetEditHistoryForPost(post.Id) + require.Nil(t, appErr) + require.NotEmpty(t, editHistory) + editHistoryPostId := editHistory[0].Id + + flagPostViaAPI(t, client, post.Id) + + // Remove the flagged post + actionRequest := &model.FlagContentActionRequest{ + Comment: "Removing this post due to policy violation", + } + + resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, actionRequest) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify file attachments are removed from database + fileInfosAfter, err2 := th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, true, false) + require.NoError(t, err2) + require.Empty(t, fileInfosAfter, "File attachments should be removed from database after removing flagged post") + + // Verify edit history posts are removed from database + editHistoryAfter, appErr := th.App.GetEditHistoryForPost(post.Id) + require.NotNil(t, appErr) + require.Equal(t, http.StatusNotFound, appErr.StatusCode, "Edit history should be removed from database after removing flagged post") + require.Empty(t, editHistoryAfter) + + // Verify the edit history post is also permanently deleted + _, err2 = th.App.Srv().Store().Post().GetSingle(th.Context, editHistoryPostId, true) + require.Error(t, err2, "Edit history post should be permanently deleted") + }) +} + +func TestKeepFlaggedPost(t *testing.T) { + th := Setup(t).InitBasic(t) + + client := th.Client + + t.Run("Should return 501 when feature is disabled", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false) + config.ContentFlaggingSettings.SetDefaults() + }) + + post := th.CreatePost(t) + actionRequest := &model.FlagContentActionRequest{ + Comment: "Keeping this post", + } + + resp, err := client.KeepFlaggedPost(context.Background(), post.Id, actionRequest) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) + + t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setNonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + actionRequest := &model.FlagContentActionRequest{ + Comment: "Keeping this post", + } + + resp, err := client.KeepFlaggedPost(context.Background(), post.Id, actionRequest) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("Should return 400 when comment is required but not provided", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setCommonReviewerWithRequiredCommentConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + // Try to keep without comment + actionRequest := &model.FlagContentActionRequest{ + Comment: "", // Empty comment when required + } + + resp, err := client.KeepFlaggedPost(context.Background(), post.Id, actionRequest) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Should successfully keep flagged post when all conditions are met", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + // Now keep the flagged post + actionRequest := &model.FlagContentActionRequest{ + Comment: "Keeping this post after review", + } + + resp, err := client.KeepFlaggedPost(context.Background(), post.Id, actionRequest) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the post still exists + fetchedPost, resp, err := client.GetPost(context.Background(), post.Id, "") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, fetchedPost) + require.Equal(t, post.Id, fetchedPost.Id) + }) + + t.Run("Should successfully keep flagged post when user is team reviewer", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicTeamReviewerConfig(th) + require.Nil(t, appErr) + + post := th.CreatePost(t) + flagPostViaAPI(t, client, post.Id) + + // Now keep the flagged post + actionRequest := &model.FlagContentActionRequest{ + Comment: "Keeping this post after review", + } + + resp, err := client.KeepFlaggedPost(context.Background(), post.Id, actionRequest) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("Should preserve file attachments and edit history when keeping flagged post", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + defer th.RemoveLicense(t) + + appErr := setBasicCommonReviewerConfig(th) + require.Nil(t, appErr) + + post, fileInfo := uploadFileAndCreatePost(t, th, client) + + // Verify file info exists for the post + fileInfos, err2 := th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, false, false) + require.NoError(t, err2) + require.Len(t, fileInfos, 1) + require.Equal(t, fileInfo.Id, fileInfos[0].Id) + + // Update the post to create edit history + post.Message = "Updated message to create edit history" + updatedPost, _, err := client.UpdatePost(context.Background(), post.Id, post) + require.NoError(t, err) + require.NotNil(t, updatedPost) + require.Equal(t, "Updated message to create edit history", updatedPost.Message) + + // Verify edit history exists + editHistory, appErr := th.App.GetEditHistoryForPost(post.Id) + require.Nil(t, appErr) + require.NotEmpty(t, editHistory) + editHistoryPostId := editHistory[0].Id + + flagPostViaAPI(t, client, post.Id) + + // Keep the flagged post + actionRequest := &model.FlagContentActionRequest{ + Comment: "Keeping this post after review - content is acceptable", + } + + resp, err := client.KeepFlaggedPost(context.Background(), post.Id, actionRequest) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify file attachments are still present in database + fileInfosAfter, err2 := th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, false, false) + require.NoError(t, err2) + require.Len(t, fileInfosAfter, 1, "File attachments should be preserved after keeping flagged post") + require.Equal(t, fileInfo.Id, fileInfosAfter[0].Id) + + // Verify edit history is still present in database + editHistoryAfter, appErr := th.App.GetEditHistoryForPost(post.Id) + require.Nil(t, appErr, "Edit history should be preserved after keeping flagged post") + require.NotEmpty(t, editHistoryAfter) + require.Equal(t, editHistoryPostId, editHistoryAfter[0].Id) + + // Verify the post still exists and is accessible + fetchedPost, resp, err := client.GetPost(context.Background(), post.Id, "") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, fetchedPost) + require.Equal(t, post.Id, fetchedPost.Id) + }) +} diff --git a/server/channels/app/content_flagging_test.go b/server/channels/app/content_flagging_test.go index 6fb0fb520ab..59928898a43 100644 --- a/server/channels/app/content_flagging_test.go +++ b/server/channels/app/content_flagging_test.go @@ -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 & " + 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 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) + }) +} diff --git a/server/channels/store/storetest/post_acknowledgements_store.go b/server/channels/store/storetest/post_acknowledgements_store.go index 478ae2b26ee..d5f262bd3de 100644 --- a/server/channels/store/storetest/post_acknowledgements_store.go +++ b/server/channels/store/storetest/post_acknowledgements_store.go @@ -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") + }) +} diff --git a/server/channels/store/storetest/post_store.go b/server/channels/store/storetest/post_store.go index 9fd11e44a2d..c96e42e46e2 100644 --- a/server/channels/store/storetest/post_store.go +++ b/server/channels/store/storetest/post_store.go @@ -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) + }) +} diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 96b4e312736..cd38339a50b 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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{ diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_footer/data_spillage_footer.test.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_footer/data_spillage_footer.test.tsx new file mode 100644 index 00000000000..4c90c22c7f4 --- /dev/null +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_footer/data_spillage_footer.test.tsx @@ -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; + +describe('DataSpillageFooter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render footer with view details button', () => { + const post = TestHelper.getPostMock(); + + renderWithContext( + , + ); + + 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( + , + ); + + const viewDetailsButton = screen.getByTestId('data-spillage-action-view-details'); + await userEvent.click(viewDetailsButton); + + expect(mockedSelectPostFromRightHandSideSearch).toHaveBeenCalledTimes(1); + expect(mockedSelectPostFromRightHandSideSearch).toHaveBeenCalledWith(post); + }); +}); diff --git a/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.test.tsx b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.test.tsx new file mode 100644 index 00000000000..7d927db3a82 --- /dev/null +++ b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.test.tsx @@ -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; +const mockedUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction; +const mockedUseContentFlaggingConfig = require('components/common/hooks/useContentFlaggingFields').useContentFlaggingConfig as jest.MockedFunction; + +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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); + }); +}); diff --git a/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx index 566bdf662e5..07c0eadfd07 100644 --- a/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx +++ b/webapp/channels/src/components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal.tsx @@ -182,16 +182,20 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi isConfirmDisabled={submitting} >
-
+
{body}

- {subtext} + {subtext}
{contentFlaggingConfig?.reviewer_comment_required ? requiredCommentSectionTitle : optionalCommentSectionTitle}
@@ -212,7 +216,10 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi />
{requestError && -
+
{requestError}