From 1a6edd255a1d67c06b602258540c6b0111d3501d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 15 Sep 2026 16:32:30 -0300 Subject: [PATCH] feat(locations): filter sites by state (#117) --- .../LocationControllerTests.cs | 42 ++++++++- .../LocationDataServiceTests.cs | 89 +++++++++++++++++++ .../LocationServiceTests.cs | 89 ++++++++++++++++++- .../Controllers/LocationController.cs | 64 +++++++------ .../Implementation/LocationDataService.cs | 6 ++ .../Interfaces/ILocationDataService.cs | 2 +- SeaHaven.Services/Helpers/UsStateCodes.cs | 68 ++++++++++++++ .../Implementation/LocationService.cs | 33 ++++++- .../Interfaces/ILocationService.cs | 2 +- .../WorkOrderAccountScopeTests.cs | 4 +- 10 files changed, 362 insertions(+), 37 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs create mode 100644 SeaHaven.Services/Helpers/UsStateCodes.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index f8f3487..47e53dd 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -4,6 +4,7 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; using FluentValidation; +using FluentValidation.Results; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; @@ -25,7 +26,7 @@ public class LocationControllerTests public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() { var service = new Mock(); - service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny())) + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List { new() { Id = 1, Name = "Site" } }, @@ -34,7 +35,7 @@ public class LocationControllerTests PageSize = 10 }); - var result = await NewController(service).GetLocationList("a", 1, 10, CancellationToken.None); + var result = await NewController(service).GetLocationList("a", 1, 10, cancellationToken: CancellationToken.None); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; @@ -44,6 +45,43 @@ public class LocationControllerTests envelope.TotalPages.Should().Be(1); } + [Fact] + public async Task GetLocationList_PassesStatesFilterToService() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List(), + TotalCount = 0, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None); + + result.Should().BeOfType(); + service.VerifyAll(); + } + + [Fact] + public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ValidationException(new[] + { + new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.") + })); + + var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Validation Error"); + response.Message.Should().Contain("ZZ"); + } + [Fact] public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity() { diff --git a/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs new file mode 100644 index 0000000..0dafeaf --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationDataServiceTests.cs @@ -0,0 +1,89 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationDataServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedAsync(ApplicationDbContext ctx) + { + ctx.Locations.AddRange( + new Locations { Id = 1, Name = "Alpha Site", City = "Austin", State = "TX" }, + new Locations { Id = 2, Name = "Beta Site", City = "Dallas", State = "TX" }, + new Locations { Id = 3, Name = "Gamma Yard", City = "Kansas City", State = "MO" }, + new Locations { Id = 4, Name = "Delta Hub", City = "Indianapolis", State = "indiana" }, + new Locations { Id = 5, Name = "Epsilon Depot", City = "Houston", State = "TX" }, + new Locations { Id = 6, Name = "Zeta Lot", City = "Nowhere", State = null }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task GetListPagedAsync_WithoutStates_ReturnsAllRows() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, null, CancellationToken.None); + + totalCount.Should().Be(6); + items.Should().HaveCount(6); + } + + [Fact] + public async Task GetListPagedAsync_AppliesStatesFilterBeforeCountAndPaging() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (page1, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 2, null, new[] { "TX" }, CancellationToken.None); + + totalCount.Should().Be(3, "count must reflect the state filter, not the whole table"); + page1.Should().HaveCount(2); + page1.Select(l => l.Name).Should().ContainInOrder("Alpha Site", "Beta Site"); + + var (page2, totalCountAgain) = await new LocationDataService(ctx) + .GetListPagedAsync(2, 2, null, new[] { "TX" }, CancellationToken.None); + + totalCountAgain.Should().Be(3); + page2.Select(l => l.Name).Should().ContainSingle("Epsilon Depot"); + } + + [Fact] + public async Task GetListPagedAsync_MultipleStates_KeepSearchFilter() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, "Dallas", new[] { "TX", "MO" }, CancellationToken.None); + + totalCount.Should().Be(1); + items.Should().ContainSingle().Which.Name.Should().Be("Beta Site"); + } + + [Fact] + public async Task GetListPagedAsync_StatesFilter_ExcludesNullAndNonMatchingStates() + { + await using var ctx = NewContext(); + await SeedAsync(ctx); + + var (items, totalCount) = await new LocationDataService(ctx) + .GetListPagedAsync(1, 10, null, new[] { "IN", "INDIANA", "MO", "MISSOURI" }, CancellationToken.None); + + totalCount.Should().Be(2); + items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 838526d..c8986b6 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -118,7 +118,7 @@ public class LocationServiceTests SeedLocation(ctx, "Beta Site", "Dallas"); SeedLocation(ctx, "Gamma Yard", "Austin"); - var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None); + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", cancellationToken: CancellationToken.None); page.Items.Should().HaveCount(2); page.TotalCount.Should().Be(2); @@ -126,6 +126,93 @@ public class LocationServiceTests page.Items.Select(l => l.Name).Should().NotBeNull(); } + [Fact] + public async Task GetLocationListPagedAsync_NormalizesStates_CaseWhitespaceAndDuplicates() + { + var data = new Mock(); + IReadOnlyCollection? captured = default; + data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((_, _, _, states, _) => captured = states) + .ReturnsAsync((new List(), 0)); + + await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None); + + captured.Should().NotBeNull(); + captured.Should().BeEquivalentTo(new[] { "TX", "TEXAS", "MO", "MISSOURI" }); + captured.Should().HaveCount(4); + } + + [Fact] + public async Task GetLocationListPagedAsync_EmptyOrWhitespaceStates_PassesNoStateFilter() + { + foreach (var states in new[] { null, "", " ", " , , " }) + { + var data = new Mock(); + IReadOnlyCollection? captured = default; + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter) + .ReturnsAsync((new List(), 0)); + + var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None); + + captured.Should().BeNull($"states input '{states}' must mean no filter"); + page.Should().NotBeNull(); + } + } + + [Fact] + public async Task GetLocationListPagedAsync_InvalidStates_ThrowsValidationAndNeverQueries() + { + var data = new Mock(); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().ContainSingle(e => + e.PropertyName == "states" + && e.ErrorMessage.Contains("ZZ") + && e.ErrorMessage.Contains("QQ") + && !e.ErrorMessage.Contains("TX")); + data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes() + { + var data = new Mock(); + data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .ReturnsAsync((new List(), 0)); + + var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All); + + var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, allStates, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task GetLocationListPagedAsync_AppliesNormalizedStatesThroughDataService() + { + using var ctx = NewContext(); + SeedLocation(ctx, "Alpha Site", "Austin").State = "TX"; + SeedLocation(ctx, "Beta Site", "Dallas").State = "TX"; + SeedLocation(ctx, "Gamma Yard", "Kansas City").State = "MO"; + SeedLocation(ctx, "Delta Hub", "Indianapolis").State = "IN"; + await ctx.SaveChangesAsync(); + + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, " mo , tx ", CancellationToken.None); + + page.TotalCount.Should().Be(3); + page.Items.Select(l => l.Name).Should().BeEquivalentTo("Alpha Site", "Beta Site", "Gamma Yard"); + } + + private static LocationService NewServiceWithSpy(ILocationDataService dataService) => + new( + dataService, + Mock.Of(), + new CreateLocationValidation(), + new UpdateLocationValidation()); + [Fact] public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull() { diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 5c8bb30..4c11ff7 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -36,38 +36,46 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetLocationList")] - public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) + public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default) { - var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken); - - var data = pagedResult.Items.Select(l => new + try { - Id = l.Id, - Name = l.Name, - Title = l.Title, - Address = l.Address1, - Address1 = l.Address1, - Address2 = l.Address2, - City = l.City, - State = l.State, - ZipCode = l.Zip, - Phone = l.PhoneNumber, - Contact = (string?)null, - ContactEmail = l.Email, - Status = l.Status, - AccountId = l.AccountId - }); + var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken); - var viewModel = new Pagination_DTO + var data = pagedResult.Items.Select(l => new + { + Id = l.Id, + Name = l.Name, + Title = l.Title, + Address = l.Address1, + Address1 = l.Address1, + Address2 = l.Address2, + City = l.City, + State = l.State, + ZipCode = l.Zip, + Phone = l.PhoneNumber, + Contact = (string?)null, + ContactEmail = l.Email, + Status = l.Status, + AccountId = l.AccountId + }); + + var viewModel = new Pagination_DTO + { + Data = data, + PageNumber = page, + PageSize = pageSize, + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) + }; + + return Ok(viewModel); + } + catch (ValidationException vex) { - Data = data, - PageNumber = page, - PageSize = pageSize, - TotalCount = pagedResult.TotalCount, - TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) - }; - - return Ok(viewModel); + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } } [HttpGet("{id}")] diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 7a589e7..5ea6983 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -135,6 +135,7 @@ namespace SeaHaven.DataServices.Implementation int page, int pageSize, string? search, + IReadOnlyCollection? states, CancellationToken cancellationToken) { var query = _context.Locations.AsNoTracking(); @@ -146,6 +147,11 @@ namespace SeaHaven.DataServices.Implementation l.City!.Contains(search)); } + if (states is { Count: > 0 }) + { + query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper())); + } + var totalCount = await query.CountAsync(cancellationToken); var items = await query .OrderBy(l => l.Name) diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 8e7a51a..a2cbf6b 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -34,7 +34,7 @@ namespace SeaHaven.DataServices.Interfaces Task ExistsAsync(int id); Task CountAsync(); - Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken); Task GetDetailByIdAsync(int id, CancellationToken cancellationToken); Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); Task AddAsync(Locations location, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/UsStateCodes.cs b/SeaHaven.Services/Helpers/UsStateCodes.cs new file mode 100644 index 0000000..9d64a1a --- /dev/null +++ b/SeaHaven.Services/Helpers/UsStateCodes.cs @@ -0,0 +1,68 @@ +namespace SeaHaven.Services.Helpers +{ + public static class UsStateCodes + { + private static readonly IReadOnlyDictionary Names = new Dictionary(StringComparer.Ordinal) + { + ["AL"] = "ALABAMA", + ["AK"] = "ALASKA", + ["AZ"] = "ARIZONA", + ["AR"] = "ARKANSAS", + ["CA"] = "CALIFORNIA", + ["CO"] = "COLORADO", + ["CT"] = "CONNECTICUT", + ["DE"] = "DELAWARE", + ["FL"] = "FLORIDA", + ["GA"] = "GEORGIA", + ["HI"] = "HAWAII", + ["ID"] = "IDAHO", + ["IL"] = "ILLINOIS", + ["IN"] = "INDIANA", + ["IA"] = "IOWA", + ["KS"] = "KANSAS", + ["KY"] = "KENTUCKY", + ["LA"] = "LOUISIANA", + ["ME"] = "MAINE", + ["MD"] = "MARYLAND", + ["MA"] = "MASSACHUSETTS", + ["MI"] = "MICHIGAN", + ["MN"] = "MINNESOTA", + ["MS"] = "MISSISSIPPI", + ["MO"] = "MISSOURI", + ["MT"] = "MONTANA", + ["NE"] = "NEBRASKA", + ["NV"] = "NEVADA", + ["NH"] = "NEW HAMPSHIRE", + ["NJ"] = "NEW JERSEY", + ["NM"] = "NEW MEXICO", + ["NY"] = "NEW YORK", + ["NC"] = "NORTH CAROLINA", + ["ND"] = "NORTH DAKOTA", + ["OH"] = "OHIO", + ["OK"] = "OKLAHOMA", + ["OR"] = "OREGON", + ["PA"] = "PENNSYLVANIA", + ["RI"] = "RHODE ISLAND", + ["SC"] = "SOUTH CAROLINA", + ["SD"] = "SOUTH DAKOTA", + ["TN"] = "TENNESSEE", + ["TX"] = "TEXAS", + ["UT"] = "UTAH", + ["VT"] = "VERMONT", + ["VA"] = "VIRGINIA", + ["WA"] = "WASHINGTON", + ["WV"] = "WEST VIRGINIA", + ["WI"] = "WISCONSIN", + ["WY"] = "WYOMING" + }; + + public static IReadOnlySet All { get; } = new HashSet(Names.Keys, StringComparer.Ordinal); + + public static IReadOnlyCollection ExpandStorageValues(IEnumerable codes) + { + return codes + .SelectMany(code => new[] { code, Names[code] }) + .ToArray(); + } + } +} diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 1b301af..9c938aa 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -165,9 +165,12 @@ namespace SeaHaven.Services.Implementation int page, int pageSize, string? search, - CancellationToken cancellationToken) + string? states = null, + CancellationToken cancellationToken = default) { - var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, cancellationToken); + var stateFilter = NormalizeStateFilter(states); + var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter); + var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken); return new PagedResult { Items = items.Select(MapToDTO).ToList(), @@ -177,6 +180,32 @@ namespace SeaHaven.Services.Implementation }; } + internal static IReadOnlyCollection? NormalizeStateFilter(string? states) + { + if (string.IsNullOrWhiteSpace(states)) + return null; + + var normalized = states + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => code.ToUpperInvariant()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (normalized.Count == 0) + return null; + + var invalid = normalized.Where(code => !UsStateCodes.All.Contains(code)).ToList(); + if (invalid.Count > 0) + { + throw new ValidationException(new[] + { + new ValidationFailure("states", $"states must be valid US postal abbreviations: {string.Join(", ", invalid)}.") + }); + } + + return normalized; + } + public async Task GetLocationDetailAsync(int id, CancellationToken cancellationToken) { var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken); diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index c13ec65..661adde 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces Task LocationExistsAsync(int id); Task GetTotalLocationCountAsync(); - Task> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default); Task GetLocationDetailAsync(int id, CancellationToken cancellationToken); Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 820fde2..a081f75 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests public Task ExistsAsync(int id) => _inner.ExistsAsync(id); public Task CountAsync() => _inner.CountAsync(); public Task<(List Items, int TotalCount)> GetListPagedAsync( - int page, int pageSize, string? search, CancellationToken cancellationToken) - => _inner.GetListPagedAsync(page, pageSize, search, cancellationToken); + int page, int pageSize, string? search, IReadOnlyCollection? states, CancellationToken cancellationToken) + => _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken); public Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) => _inner.GetDetailByIdAsync(id, cancellationToken); public Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)