From 6b7c3086b06bbbebef626af803cef3ba289db0c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Jun 2025 04:57:36 +0000 Subject: [PATCH 1/4] Initial plan for issue From ac86658eb8d525c632635ecaa572b3b7c348cd04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Jun 2025 05:04:21 +0000 Subject: [PATCH 2/4] Add email and unsubscribe filters to admin users endpoint Co-authored-by: maximgorbatyuk <13348685+maximgorbatyuk@users.noreply.github.com> --- .../Users/AdminUsersControllerTests.cs | 108 ++++++++++++++++++ .../Features/Users/AdminUsersController.cs | 12 +- .../SearchUsersForAdminQueryParams.cs | 16 +++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs create mode 100644 src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs diff --git a/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs b/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs new file mode 100644 index 00000000..f24cd26d --- /dev/null +++ b/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs @@ -0,0 +1,108 @@ +using System.Threading.Tasks; +using Domain.Enums; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.Users; +using Web.Api.Features.Users.SearchUsersForAdmin; +using Xunit; + +namespace Web.Api.Tests.Features.Users; + +public class AdminUsersControllerTests +{ + [Fact] + public async Task All_WithEmailFilter_ReturnsMatchingUsers() + { + await using var context = new InMemoryDatabaseContext(); + var admin = await new UserFake(Role.Admin).PleaseAsync(context); + var user1 = await new UserFake(Role.Interviewer, userName: "john.doe@example.com").PleaseAsync(context); + var user2 = await new UserFake(Role.Interviewer, userName: "jane.smith@example.com").PleaseAsync(context); + + var controller = new AdminUsersController( + new FakeAuth(admin), + context); + + var queryParams = new SearchUsersForAdminQueryParams + { + Email = "john" + }; + + context.ChangeTracker.Clear(); + var result = await controller.All(queryParams); + + Assert.Single(result.Results); + Assert.Equal(user1.Id, result.Results[0].Id); + } + + [Fact] + public async Task All_WithUnsubscribeFilter_ReturnsMatchingUsers() + { + await using var context = new InMemoryDatabaseContext(); + var admin = await new UserFake(Role.Admin).PleaseAsync(context); + var user1 = await new UserFake(Role.Interviewer).WithUnsubscribeMeFromAll(true).PleaseAsync(context); + var user2 = await new UserFake(Role.Interviewer).WithUnsubscribeMeFromAll(false).PleaseAsync(context); + + var controller = new AdminUsersController( + new FakeAuth(admin), + context); + + var queryParams = new SearchUsersForAdminQueryParams + { + UnsubscribeMeFromAll = true + }; + + context.ChangeTracker.Clear(); + var result = await controller.All(queryParams); + + Assert.Single(result.Results); + Assert.Equal(user1.Id, result.Results[0].Id); + } + + [Fact] + public async Task All_WithBothFilters_ReturnsMatchingUsers() + { + await using var context = new InMemoryDatabaseContext(); + var admin = await new UserFake(Role.Admin).PleaseAsync(context); + var user1 = await new UserFake(Role.Interviewer, userName: "john.doe@example.com").WithUnsubscribeMeFromAll(true).PleaseAsync(context); + var user2 = await new UserFake(Role.Interviewer, userName: "john.smith@example.com").WithUnsubscribeMeFromAll(false).PleaseAsync(context); + var user3 = await new UserFake(Role.Interviewer, userName: "jane.doe@example.com").WithUnsubscribeMeFromAll(true).PleaseAsync(context); + + var controller = new AdminUsersController( + new FakeAuth(admin), + context); + + var queryParams = new SearchUsersForAdminQueryParams + { + Email = "john", + UnsubscribeMeFromAll = true + }; + + context.ChangeTracker.Clear(); + var result = await controller.All(queryParams); + + Assert.Single(result.Results); + Assert.Equal(user1.Id, result.Results[0].Id); + } + + [Fact] + public async Task All_WithNoFilters_ReturnsAllActiveUsers() + { + await using var context = new InMemoryDatabaseContext(); + var admin = await new UserFake(Role.Admin).PleaseAsync(context); + var user1 = await new UserFake(Role.Interviewer).PleaseAsync(context); + var user2 = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var controller = new AdminUsersController( + new FakeAuth(admin), + context); + + var queryParams = new SearchUsersForAdminQueryParams(); + + context.ChangeTracker.Clear(); + var result = await controller.All(queryParams); + + // Should return all 3 users (admin + 2 test users) + Assert.Equal(3, result.Results.Count); + } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Users/AdminUsersController.cs b/src/Web.Api/Features/Users/AdminUsersController.cs index 67648a93..c8961e5b 100644 --- a/src/Web.Api/Features/Users/AdminUsersController.cs +++ b/src/Web.Api/Features/Users/AdminUsersController.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Web.Api.Features.Users.Models; +using Web.Api.Features.Users.SearchUsersForAdmin; using Web.Api.Setup.Attributes; namespace Web.Api.Features.Users; @@ -35,19 +36,24 @@ public AdminUsersController( [HttpGet("")] public async Task> All( - [FromQuery] PageModel pageParams = null) + [FromQuery] SearchUsersForAdminQueryParams queryParams = null) { await _auth.HasRoleOrFailAsync(Role.Admin); - pageParams ??= PageModel.Default; + queryParams ??= new SearchUsersForAdminQueryParams(); + + var emailFilter = queryParams.Email?.Trim().ToLowerInvariant(); + return await _context.Users .AsNoTracking() .Include(x => x.UserRoles) .Include(x => x.Salaries) .Where(x => x.DeletedAt == null) + .When(queryParams.HasEmailFilter(), x => x.Email.ToLower().Contains(emailFilter)) + .When(queryParams.HasUnsubscribeFilter(), x => x.UnsubscribeMeFromAll == queryParams.UnsubscribeMeFromAll.Value) .OrderBy(x => x.CreatedAt) .Select(UserDto.Transformation) - .AsPaginatedAsync(pageParams); + .AsPaginatedAsync(queryParams); } [HttpGet("{id:long}")] diff --git a/src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs b/src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs new file mode 100644 index 00000000..70e5b9f9 --- /dev/null +++ b/src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs @@ -0,0 +1,16 @@ +using Domain.ValueObjects.Pagination; + +namespace Web.Api.Features.Users.SearchUsersForAdmin; + +public record SearchUsersForAdminQueryParams : PageModel +{ + public string Email { get; init; } = string.Empty; + + public bool? UnsubscribeMeFromAll { get; init; } + + public bool HasEmailFilter() + => !string.IsNullOrWhiteSpace(Email); + + public bool HasUnsubscribeFilter() + => UnsubscribeMeFromAll.HasValue; +} \ No newline at end of file From 19dc5c48eb9129043a748256083fd9b9deac1f2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Jun 2025 05:05:49 +0000 Subject: [PATCH 3/4] Add defensive null check and additional test coverage Co-authored-by: maximgorbatyuk <13348685+maximgorbatyuk@users.noreply.github.com> --- .../Users/AdminUsersControllerTests.cs | 23 +++++++++++++++++++ .../Features/Users/AdminUsersController.cs | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs b/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs index f24cd26d..98800c57 100644 --- a/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs +++ b/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs @@ -105,4 +105,27 @@ public async Task All_WithNoFilters_ReturnsAllActiveUsers() // Should return all 3 users (admin + 2 test users) Assert.Equal(3, result.Results.Count); } + + [Fact] + public async Task All_WithEmptyEmailFilter_ReturnsAllActiveUsers() + { + await using var context = new InMemoryDatabaseContext(); + var admin = await new UserFake(Role.Admin).PleaseAsync(context); + var user1 = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var controller = new AdminUsersController( + new FakeAuth(admin), + context); + + var queryParams = new SearchUsersForAdminQueryParams + { + Email = "" // Empty string should not filter + }; + + context.ChangeTracker.Clear(); + var result = await controller.All(queryParams); + + // Should return all users since empty email filter is ignored + Assert.Equal(2, result.Results.Count); + } } \ No newline at end of file diff --git a/src/Web.Api/Features/Users/AdminUsersController.cs b/src/Web.Api/Features/Users/AdminUsersController.cs index c8961e5b..066e4cf2 100644 --- a/src/Web.Api/Features/Users/AdminUsersController.cs +++ b/src/Web.Api/Features/Users/AdminUsersController.cs @@ -49,7 +49,7 @@ public async Task> All( .Include(x => x.UserRoles) .Include(x => x.Salaries) .Where(x => x.DeletedAt == null) - .When(queryParams.HasEmailFilter(), x => x.Email.ToLower().Contains(emailFilter)) + .When(queryParams.HasEmailFilter(), x => x.Email != null && x.Email.ToLower().Contains(emailFilter)) .When(queryParams.HasUnsubscribeFilter(), x => x.UnsubscribeMeFromAll == queryParams.UnsubscribeMeFromAll.Value) .OrderBy(x => x.CreatedAt) .Select(UserDto.Transformation) From 832b8d693ca3b3e465ff47f255bb60042ac2c52d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Jun 2025 06:15:11 +0000 Subject: [PATCH 4/4] Fix test compilation and style issues in AdminUsersControllerTests Co-authored-by: maximgorbatyuk <13348685+maximgorbatyuk@users.noreply.github.com> --- .../Users/AdminUsersControllerTests.cs | 19 ++++++++++--------- .../Features/Users/AdminUsersController.cs | 3 +-- .../SearchUsersForAdminQueryParams.cs | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs b/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs index 98800c57..b4d93cff 100644 --- a/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs +++ b/src/Web.Api.Tests/Features/Users/AdminUsersControllerTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading.Tasks; using Domain.Enums; using TestUtils.Auth; @@ -30,9 +31,9 @@ public async Task All_WithEmailFilter_ReturnsMatchingUsers() context.ChangeTracker.Clear(); var result = await controller.All(queryParams); - + Assert.Single(result.Results); - Assert.Equal(user1.Id, result.Results[0].Id); + Assert.Equal(user1.Id, result.Results.First().Id); } [Fact] @@ -54,9 +55,9 @@ public async Task All_WithUnsubscribeFilter_ReturnsMatchingUsers() context.ChangeTracker.Clear(); var result = await controller.All(queryParams); - + Assert.Single(result.Results); - Assert.Equal(user1.Id, result.Results[0].Id); + Assert.Equal(user1.Id, result.Results.First().Id); } [Fact] @@ -80,9 +81,9 @@ public async Task All_WithBothFilters_ReturnsMatchingUsers() context.ChangeTracker.Clear(); var result = await controller.All(queryParams); - + Assert.Single(result.Results); - Assert.Equal(user1.Id, result.Results[0].Id); + Assert.Equal(user1.Id, result.Results.First().Id); } [Fact] @@ -101,7 +102,7 @@ public async Task All_WithNoFilters_ReturnsAllActiveUsers() context.ChangeTracker.Clear(); var result = await controller.All(queryParams); - + // Should return all 3 users (admin + 2 test users) Assert.Equal(3, result.Results.Count); } @@ -119,12 +120,12 @@ public async Task All_WithEmptyEmailFilter_ReturnsAllActiveUsers() var queryParams = new SearchUsersForAdminQueryParams { - Email = "" // Empty string should not filter + Email = string.Empty // Empty string should not filter }; context.ChangeTracker.Clear(); var result = await controller.All(queryParams); - + // Should return all users since empty email filter is ignored Assert.Equal(2, result.Results.Count); } diff --git a/src/Web.Api/Features/Users/AdminUsersController.cs b/src/Web.Api/Features/Users/AdminUsersController.cs index 066e4cf2..630740b9 100644 --- a/src/Web.Api/Features/Users/AdminUsersController.cs +++ b/src/Web.Api/Features/Users/AdminUsersController.cs @@ -41,9 +41,8 @@ public async Task> All( await _auth.HasRoleOrFailAsync(Role.Admin); queryParams ??= new SearchUsersForAdminQueryParams(); - + var emailFilter = queryParams.Email?.Trim().ToLowerInvariant(); - return await _context.Users .AsNoTracking() .Include(x => x.UserRoles) diff --git a/src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs b/src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs index 70e5b9f9..c1109279 100644 --- a/src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs +++ b/src/Web.Api/Features/Users/SearchUsersForAdmin/SearchUsersForAdminQueryParams.cs @@ -1,4 +1,4 @@ -using Domain.ValueObjects.Pagination; +using Domain.ValueObjects.Pagination; namespace Web.Api.Features.Users.SearchUsersForAdmin;