mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
feat(locations): support site registry queries (SH-330)
This commit is contained in:
parent
d204536215
commit
660ebac628
11 changed files with 410 additions and 24 deletions
|
|
@ -26,7 +26,7 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
||||
|
|
@ -49,7 +49,7 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_PassesStatesFilterToService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO>(),
|
||||
|
|
@ -58,7 +58,7 @@ public class LocationControllerTests
|
|||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.VerifyAll();
|
||||
|
|
@ -68,13 +68,13 @@ public class LocationControllerTests
|
|||
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>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.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 result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
|
|
@ -200,4 +200,90 @@ public class LocationControllerTests
|
|||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_ForwardsSortParamsToService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO> { Items = new List<LocationDTO>(), TotalCount = 0, Page = 1, PageSize = 10 });
|
||||
|
||||
await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None);
|
||||
|
||||
service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_WhenSortInvalid_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>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.")
|
||||
}));
|
||||
|
||||
var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: 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("sortBy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), null, null))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Name = "STL8",
|
||||
Address1 = "9 River Rd",
|
||||
City = "St. Louis",
|
||||
State = "MO",
|
||||
Zip = "63101",
|
||||
PhoneNumber = "555-0100",
|
||||
Email = "poc@example.com",
|
||||
AccountId = 3,
|
||||
AccountName = "Acme Co",
|
||||
Contacts = new List<SiteContactResponseDTO>
|
||||
{
|
||||
new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" },
|
||||
new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" }
|
||||
}
|
||||
}
|
||||
},
|
||||
TotalCount = 1,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value);
|
||||
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
|
||||
|
||||
row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field");
|
||||
row.GetProperty("Address").GetString().Should().Be("9 River Rd");
|
||||
row.GetProperty("City").GetString().Should().Be("St. Louis");
|
||||
row.GetProperty("State").GetString().Should().Be("MO");
|
||||
row.GetProperty("ZipCode").GetString().Should().Be("63101");
|
||||
row.GetProperty("Phone").GetString().Should().Be("555-0100");
|
||||
row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com");
|
||||
row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field");
|
||||
row.GetProperty("AccountId").GetInt32().Should().Be(3);
|
||||
row.GetProperty("ClientName").GetString().Should().Be("Acme Co");
|
||||
row.GetProperty("AccountName").GetString().Should().Be("Acme Co");
|
||||
var contacts = row.GetProperty("Contacts");
|
||||
contacts.GetArrayLength().Should().Be(2);
|
||||
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
|
||||
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,4 +86,129 @@ public class LocationDataServiceTests
|
|||
totalCount.Should().Be(2);
|
||||
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
|
||||
}
|
||||
|
||||
private static async Task SeedRegistryAsync(ApplicationDbContext ctx)
|
||||
{
|
||||
ctx.Accounts.AddRange(
|
||||
new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false },
|
||||
new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false });
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 },
|
||||
new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 },
|
||||
new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 },
|
||||
new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 });
|
||||
ctx.Contacts.AddRange(
|
||||
new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" },
|
||||
new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" },
|
||||
new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" },
|
||||
new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true },
|
||||
new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("STL", new[] { 10 }, "site code")]
|
||||
[InlineData("Borealis", new[] { 11, 13 }, "client name")]
|
||||
[InlineData("Prairie", new[] { 11 }, "street address")]
|
||||
[InlineData("Miami", new[] { 13 }, "city")]
|
||||
public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField)
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, totalCount) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, term, null, CancellationToken.None);
|
||||
|
||||
totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}");
|
||||
items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("code", 11, 12, 13, 10)]
|
||||
[InlineData("client", 10, 12, 11, 13)]
|
||||
[InlineData("address", 13, 11, 12, 10)]
|
||||
[InlineData("city", 11, 12, 13, 10)]
|
||||
[InlineData("state", 13, 12, 10, 11)]
|
||||
[InlineData("poc", 13, 12, 10, 11)]
|
||||
public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak(
|
||||
string sortBy, params int[] expectedIds)
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc");
|
||||
|
||||
items.Select(l => l.Id).Should().ContainInOrder(expectedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc");
|
||||
|
||||
// DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must
|
||||
// sort last; including the deleted row ("Removed Poc") would instead place it second.
|
||||
items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc");
|
||||
|
||||
items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways");
|
||||
|
||||
items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 20, Name = "HOU1", City = "Dallas" },
|
||||
new Locations { Id = 21, Name = "AUS3", City = "Dallas" },
|
||||
new Locations { Id = 22, Name = "ELP4", City = "Dallas" });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var service = new LocationDataService(ctx);
|
||||
|
||||
var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc");
|
||||
var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc");
|
||||
|
||||
totalCount.Should().Be(3);
|
||||
page1.Select(l => l.Id).Should().ContainInOrder(20, 21);
|
||||
page2.Select(l => l.Id).Should().ContainInOrder(22);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
|
||||
|
||||
items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ public class LocationServiceTests
|
|||
{
|
||||
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)
|
||||
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
|
||||
|
|
@ -149,8 +149,8 @@ public class LocationServiceTests
|
|||
{
|
||||
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)
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
|
||||
|
|
@ -173,14 +173,14 @@ public class LocationServiceTests
|
|||
&& 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);
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), 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>()))
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
|
||||
|
|
@ -533,4 +533,97 @@ public class LocationServiceTests
|
|||
again.Should().BeFalse();
|
||||
ctx.Locations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
string? capturedSort = "sentinel";
|
||||
string? capturedDirection = "sentinel";
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
|
||||
{
|
||||
capturedSort = sortBy;
|
||||
capturedDirection = sortDirection;
|
||||
})
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC ");
|
||||
|
||||
capturedSort.Should().Be("poc");
|
||||
capturedDirection.Should().Be("desc");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", " ")]
|
||||
public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults(
|
||||
string? sortBy,
|
||||
string? sortDirection)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
string? capturedSort = "sentinel";
|
||||
string? capturedDirection = "sentinel";
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
|
||||
{
|
||||
capturedSort = s;
|
||||
capturedDirection = d;
|
||||
})
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection);
|
||||
|
||||
capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering");
|
||||
capturedDirection.Should().Be("asc");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rating")]
|
||||
[InlineData("password; drop table locations")]
|
||||
public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e =>
|
||||
e.PropertyName == "sortBy"
|
||||
&& e.ErrorMessage.Contains("code")
|
||||
&& e.ErrorMessage.Contains("poc"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ascending")]
|
||||
[InlineData("DESC; drop table locations")]
|
||||
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e =>
|
||||
e.PropertyName == "sortDirection"
|
||||
&& e.ErrorMessage.Contains("asc")
|
||||
&& e.ErrorMessage.Contains("desc"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccount(ctx, 9, "Acme Co");
|
||||
SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
var row = page.Items.Should().ContainSingle().Subject;
|
||||
row.AccountId.Should().Be(9);
|
||||
row.AccountName.Should().Be("Acme Co");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ public class LocationSiteContactsTests
|
|||
public async Task List_LoadsPageContactsInSingleBatchedRead()
|
||||
{
|
||||
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>()))
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((new List<Locations>
|
||||
{
|
||||
new() { Id = 1, Name = "One" },
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("GetLocationList")]
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
|
||||
|
||||
var data = pagedResult.Items.Select(l => new
|
||||
{
|
||||
|
|
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = l.Email,
|
||||
Status = l.Status,
|
||||
AccountId = l.AccountId,
|
||||
ClientName = l.AccountName,
|
||||
AccountName = l.AccountName,
|
||||
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation
|
|||
int pageSize,
|
||||
string? search,
|
||||
IReadOnlyCollection<string>? states,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
string? sortBy = null,
|
||||
string? sortDirection = null)
|
||||
{
|
||||
var query = _context.Locations.AsNoTracking();
|
||||
IQueryable<Locations> query = _context.Locations
|
||||
.AsNoTracking()
|
||||
.Include(l => l.Account);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(l =>
|
||||
l.Name!.Contains(search) ||
|
||||
l.City!.Contains(search));
|
||||
l.Address1!.Contains(search) ||
|
||||
l.City!.Contains(search) ||
|
||||
l.Account != null && l.Account.Name!.Contains(search));
|
||||
}
|
||||
|
||||
if (states is { Count: > 0 })
|
||||
|
|
@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation
|
|||
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
|
||||
}
|
||||
|
||||
query = ApplyListSort(query, sortBy, sortDirection);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderBy(l => l.Name)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
|
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
private static IQueryable<Locations> ApplyListSort(IQueryable<Locations> query, string? sortBy, string? sortDirection)
|
||||
{
|
||||
var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase);
|
||||
IOrderedQueryable<Locations> ordered = (sortBy?.Trim().ToLowerInvariant()) switch
|
||||
{
|
||||
"code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name),
|
||||
"client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name),
|
||||
"address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1),
|
||||
"city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City),
|
||||
"state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State),
|
||||
"poc" => descending
|
||||
? query.OrderByDescending(FirstActiveContactName)
|
||||
: query.OrderBy(FirstActiveContactName),
|
||||
_ => query.OrderBy(l => l.Name)
|
||||
};
|
||||
|
||||
return ordered.ThenBy(l => l.Id);
|
||||
}
|
||||
|
||||
private static readonly System.Linq.Expressions.Expression<Func<Locations, string?>> FirstActiveContactName =
|
||||
l => l.Contacts
|
||||
.Where(c => c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.Select(c => c.FirstName)
|
||||
.FirstOrDefault();
|
||||
|
||||
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Locations
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ 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, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order.
|
||||
/// Sort options trail the token so existing call sites stay source-compatible.
|
||||
/// </summary>
|
||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null);
|
||||
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Email { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
public string? AccountName { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -166,11 +166,15 @@ namespace SeaHaven.Services.Implementation
|
|||
int pageSize,
|
||||
string? search,
|
||||
string? states = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
string? sortBy = null,
|
||||
string? sortDirection = null)
|
||||
{
|
||||
var stateFilter = NormalizeStateFilter(states);
|
||||
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
|
||||
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
|
||||
var sort = NormalizeSortColumn(sortBy);
|
||||
var direction = NormalizeSortDirection(sortDirection);
|
||||
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken, sort, direction);
|
||||
|
||||
// SH-138: one bounded contact read for the whole page, never per row.
|
||||
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
|
||||
|
|
@ -184,6 +188,42 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
internal static readonly IReadOnlyCollection<string> SortableColumns = new[] { "code", "client", "address", "city", "state", "poc" };
|
||||
|
||||
internal static string? NormalizeSortColumn(string? sortBy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sortBy))
|
||||
return null;
|
||||
|
||||
var normalized = sortBy.Trim().ToLowerInvariant();
|
||||
if (!SortableColumns.Contains(normalized))
|
||||
{
|
||||
throw new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("sortBy", $"sortBy must be one of: {string.Join(", ", SortableColumns)}.")
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static string NormalizeSortDirection(string? sortDirection)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sortDirection))
|
||||
return "asc";
|
||||
|
||||
var normalized = sortDirection.Trim().ToLowerInvariant();
|
||||
if (normalized != "asc" && normalized != "desc")
|
||||
{
|
||||
throw new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("sortDirection", "sortDirection must be asc or desc.")
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static IReadOnlyCollection<string>? NormalizeStateFilter(string? states)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(states))
|
||||
|
|
@ -503,6 +543,7 @@ namespace SeaHaven.Services.Implementation
|
|||
Email = location.Email,
|
||||
Status = location.Status,
|
||||
AccountId = location.AccountId,
|
||||
AccountName = location.Account?.Name,
|
||||
CreatedDate = location.CreatedDate,
|
||||
CreatedBy = location.createdby,
|
||||
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)
|
||||
|
|
|
|||
|
|
@ -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, string? states = null, CancellationToken cancellationToken = default);
|
||||
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default, string? sortBy = null, string? sortDirection = null);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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, IReadOnlyCollection<string>? states, CancellationToken cancellationToken)
|
||||
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null)
|
||||
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
|
||||
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
||||
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue