feat(locations): filter sites by state (#117)
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions

This commit is contained in:
Alexandre Brandizzi 2026-09-15 16:32:30 -03:00 • committed by GitHub
parent 3a4af64a64
commit 1a6edd255a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 362 additions and 37 deletions

View file

@ -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<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO> { 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<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
@ -44,6 +45,43 @@ public class LocationControllerTests
envelope.TotalPages.Should().Be(1);
}
[Fact]
public async Task GetLocationList_PassesStatesFilterToService()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO>(),
TotalCount = 0,
Page = 1,
PageSize = 10
});
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.VerifyAll();
}
[Fact]
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Validation Error");
response.Message.Should().Contain("ZZ");
}
[Fact]
public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity()
{

View file

@ -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<ApplicationDbContext>()
.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");
}
}

View file

@ -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<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.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<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<Locations>(), 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<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation());
[Fact]
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
{

View file

@ -36,38 +36,46 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public async Task<IActionResult> 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}")]

View file

@ -135,6 +135,7 @@ namespace SeaHaven.DataServices.Implementation
int page,
int pageSize,
string? search,
IReadOnlyCollection<string>? 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)

View file

@ -34,7 +34,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<bool> ExistsAsync(int id);
Task<int> CountAsync();
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);

View file

@ -0,0 +1,68 @@
namespace SeaHaven.Services.Helpers
{
public static class UsStateCodes
{
private static readonly IReadOnlyDictionary<string, string> Names = new Dictionary<string, string>(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<string> All { get; } = new HashSet<string>(Names.Keys, StringComparer.Ordinal);
public static IReadOnlyCollection<string> ExpandStorageValues(IEnumerable<string> codes)
{
return codes
.SelectMany(code => new[] { code, Names[code] })
.ToArray();
}
}
}

View file

@ -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<LocationDTO>
{
Items = items.Select(MapToDTO).ToList(),
@ -177,6 +180,32 @@ namespace SeaHaven.Services.Implementation
};
}
internal static IReadOnlyCollection<string>? 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<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken)
{
var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken);

View file

@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces
Task<bool> LocationExistsAsync(int id);
Task<int> GetTotalLocationCountAsync();
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default);
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);

View file

@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
public Task<int> CountAsync() => _inner.CountAsync();
public Task<(List<Locations> 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<string>? states, CancellationToken cancellationToken)
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken);
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
=> _inner.GetDetailByIdAsync(id, cancellationToken);
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)