mattermost/server/channels/app/report_test.go
Maria A Nunez 2efee7ec28
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Add single-channel guests filter and channel count column to System Console Users (#35517)
* Add single-channel guests filter and channel count column to System Console Users

- Add guest_filter query parameter to Reports API with store-level
  filtering by guest channel membership count (all, single_channel,
  multi_channel)
- Add channel_count field to user report responses and CSV exports
- Add grouped guest role filter options in the filter popover
- Add toggleable Channel count column to the users table
- Add GuestFilter and SearchTerm to Go client GetUsersForReporting
- Add tests: API parsing, API integration, app job dedup, webapp utils,
  E2E column data rendering

Made-with: Cursor

* Fix gofmt alignment and isolate guest store tests

- Align GuestFilter constants to satisfy gofmt
- Move guest user/channel setup into a nested sub-test to avoid
  breaking existing ordering and role filter assertions

Made-with: Cursor

* Exclude archived channels from guest filter queries and ChannelCount

The ChannelMembers subqueries for guest_filter (single/multi channel)
and the ChannelCount column did not join with Channels to check
DeleteAt = 0. Since channel archival soft-deletes (sets DeleteAt) but
leaves ChannelMembers rows intact, archived channel memberships were
incorrectly counted, potentially misclassifying guests between
single-channel and multi-channel filters and inflating ChannelCount.

- Join ChannelMembers with Channels (DeleteAt = 0) in all three
  subqueries in applyUserReportFilter and GetUserReport
- Add store test covering archived channel exclusion
- Tighten existing guest filter test assertions with found-flags
  and exact count checks

Made-with: Cursor

* Exclude DM/GM from guest channel counts, validate GuestFilter, fix dropdown divider

- Scope ChannelCount and guest filter subqueries to Open/Private channel
  types only (exclude DM and GM), so a guest with one team channel plus
  a DM is correctly classified as single-channel
- Add GuestFilter validation in UserReportOptions.IsValid with
  AllowedGuestFilters whitelist
- Add API test for invalid guest_filter rejection (400)
- Add store regression test for DM/GM exclusion
- Fix role filter dropdown: hide the divider above the first group
  heading via CSS rule on DropDown__group:first-child
- Update E2E test label to match "Guests in a single channel" wording

Made-with: Cursor

* Add store test coverage for private and GM channel types

Private channels (type P) should be counted in ChannelCount and guest
filters, while GM channels (type G) should not. Add a test that creates
a guest with memberships in an open channel, a private channel, and a
GM, then asserts ChannelCount = 2, multi-channel filter includes the
guest, and single-channel filter excludes them.

Made-with: Cursor

* Add server i18n translation for invalid_guest_filter error

The new error ID model.user_report_options.is_valid.invalid_guest_filter
was missing from server/i18n/en.json, causing CI to fail.

Made-with: Cursor

* Make filter dropdown dividers full width

Remove the horizontal inset from grouped dropdown separators so the
system user role filter dividers span edge to edge across the menu.
Leave the unrelated webapp/package-lock.json change uncommitted.

Made-with: Cursor

* Optimize guest channel report filters.

Use per-user channel count subqueries for the single- and multi-channel guest filters so the report avoids aggregating all channel memberships before filtering guests.
2026-03-12 12:50:53 -04:00

238 lines
6.9 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"strconv"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
type MockReportable struct {
TestField1 string
TestField2 int
TestField3 time.Time
}
func (mr *MockReportable) ToReport() []string {
return []string{
mr.TestField1,
strconv.Itoa(mr.TestField2),
mr.TestField3.Format("2006-01-02"),
}
}
var testData []model.ReportableObject = []model.ReportableObject{
&MockReportable{
TestField1: "some-name",
TestField2: 400,
TestField3: time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local),
},
&MockReportable{
TestField1: "some-other-name",
TestField2: 500,
TestField3: time.Date(2023, 1, 1, 0, 0, 0, 0, time.Local),
},
&MockReportable{
TestField1: "some-other-other-name",
TestField2: 600,
TestField3: time.Date(2022, 1, 1, 0, 0, 0, 0, time.Local),
},
}
func TestSaveReportChunk(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("should write CSV chunk to file", func(t *testing.T) {
prefix := model.NewId()
err := th.App.SaveReportChunk("csv", prefix, 999, []model.ReportableObject{testData[0]})
require.Nil(t, err)
filePath := fmt.Sprintf("admin_reports/batch_report_%s__999.csv", prefix)
bytes, err := th.App.ReadFile(filePath)
require.Nil(t, err)
require.NotNil(t, bytes)
require.Equal(t, "some-name,400,2024-01-01\n", string(bytes))
})
t.Run("should fail if the report format is not supported", func(t *testing.T) {
err := th.App.SaveReportChunk("zzz", model.NewId(), 999, []model.ReportableObject{testData[0]})
require.NotNil(t, err)
})
}
func TestCompileReportChunks(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
prefix := model.NewId()
err := th.App.SaveReportChunk("csv", prefix, 0, []model.ReportableObject{testData[0]})
require.Nil(t, err)
err = th.App.SaveReportChunk("csv", prefix, 1, []model.ReportableObject{testData[1]})
require.Nil(t, err)
err = th.App.SaveReportChunk("csv", prefix, 2, []model.ReportableObject{testData[2]})
require.Nil(t, err)
t.Run("should compile a bunch of report chunks", func(t *testing.T) {
compileErr := th.App.CompileReportChunks("csv", prefix, 3, []string{"Name", "NumPosts", "StartDate"})
require.Nil(t, compileErr)
filePath := fmt.Sprintf("admin_reports/batch_report_%s.csv", prefix)
bytes, readErr := th.App.ReadFile(filePath)
require.Nil(t, readErr)
require.NotNil(t, bytes)
expected := `Name,NumPosts,StartDate
some-name,400,2024-01-01
some-other-name,500,2023-01-01
some-other-other-name,600,2022-01-01
`
require.Equal(t, expected, string(bytes))
})
t.Run("should fail if the report format is not supported", func(t *testing.T) {
err = th.App.CompileReportChunks("zzz", prefix, 3, []string{"Name", "NumPosts", "StartDate"})
require.NotNil(t, err)
})
t.Run("should fail if a chunk is missing", func(t *testing.T) {
err = th.App.CompileReportChunks("csv", prefix, 4, []string{"Name", "NumPosts", "StartDate"})
require.NotNil(t, err)
})
}
func TestCheckForExistingJobs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("should return error if job with same options exists in pending jobs", func(t *testing.T) {
app := th.App
options := map[string]string{
"date_range": "last_30_days",
"requesting_user_id": th.BasicUser.Id,
"role": "user",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
jobType := model.JobTypeExportUsersToCSV
// Create a pending job with same options
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, options)
defer func() {
_ = app.Srv().Jobs.RequestCancellation(th.Context, job.Id)
}()
require.Nil(t, err)
require.NotNil(t, job)
// checkForExistingJobs
appErr := app.checkForExistingJobs(th.Context, options, jobType)
require.NotNil(t, appErr)
require.Equal(t, "app.report.start_users_batch_export.job_exists", appErr.Id)
})
t.Run("should return error if job with same options exists in in-progress jobs", func(t *testing.T) {
app := th.App
options := map[string]string{
"date_range": "last_30_days",
"requesting_user_id": th.BasicUser.Id,
"role": "user",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
jobType := model.JobTypeExportUsersToCSV
// Create an in-progress job with same options
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, options)
defer func() {
_ = app.Srv().Jobs.RequestCancellation(th.Context, job.Id)
}()
require.Nil(t, err)
require.NotNil(t, job)
// Manually set job status to in-progress
err = app.Srv().Jobs.SetJobProgress(job, 60)
require.Nil(t, err)
// Call checkForExistingJobs
appErr := app.checkForExistingJobs(th.Context, options, jobType)
require.NotNil(t, appErr)
require.Equal(t, "app.report.start_users_batch_export.job_exists", appErr.Id)
})
t.Run("should not return error if existing jobs have different options", func(t *testing.T) {
app := th.App
options := map[string]string{
"date_range": "last_30_days",
"requesting_user_id": th.BasicUser.Id,
"role": "user",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
jobType := model.JobTypeExportUsersToCSV
differentOptions := map[string]string{
"date_range": "all_time",
"requesting_user_id": th.BasicUser2.Id,
"role": "admin",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, differentOptions)
require.Nil(t, err)
require.NotNil(t, job)
// Call checkForExistingJobs
appErr := app.checkForExistingJobs(th.Context, options, jobType)
require.Nil(t, appErr)
})
t.Run("should not return error if existing job has different guest_filter", func(t *testing.T) {
app := th.App
options := map[string]string{
"date_range": "last_30_days",
"requesting_user_id": th.BasicUser.Id,
"role": "",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "single_channel",
}
jobType := model.JobTypeExportUsersToCSV
existingJobOptions := map[string]string{
"date_range": "last_30_days",
"requesting_user_id": th.BasicUser.Id,
"role": "",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "multi_channel",
}
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, existingJobOptions)
require.Nil(t, err)
require.NotNil(t, job)
appErr := app.checkForExistingJobs(th.Context, options, jobType)
require.Nil(t, appErr)
})
}