fix(vendors): preserve technician list contract

This commit is contained in:
Alexandre Brandizzi 2026-09-14 15:24:55 -03:00
parent 3eb56a690b
commit 35b647c0f2
7 changed files with 371 additions and 15 deletions

View file

@ -39,7 +39,7 @@ public class VendorControllerTests
var service = new Mock<IVendorService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
service.Setup(x => x.GetVendorDirectoryPagedAsync(
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
1,
10,
"clayton",
@ -53,7 +53,7 @@ public class VendorControllerTests
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
service.Verify(x => x.GetVendorDirectoryPagedAsync(
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
1,
10,
"clayton",
@ -63,6 +63,175 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
{
Items = new List<VendorDirectoryItemDTO>
{
new() { Id = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
new() { Id = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
},
TotalCount = 2,
Page = 1,
PageSize = 10
});
var result = await NewController(service).GetVendorList();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
var rows = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().ToList();
// Legacy contract: same-company technicians stay as distinct top-level rows
// carrying their own vendor ids, and the response shape has no grouped
// Technicians property.
rows.Should().HaveCount(2);
rows.Select(r => r.GetType().GetProperty("Id")!.GetValue(r)).Should().BeEquivalentTo(new[] { 7, 9 });
rows.Should().OnlyContain(r => r.GetType().GetProperty("Technicians") == null);
}
[Fact]
public async Task GetVendorList_PreservesLegacyPageSizeAboveGroupedDirectoryBound()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
1,
500,
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO> { TotalCount = 420, Page = 1, PageSize = 500 });
var result = await NewController(service).GetVendorList(page: 1, pageSize: 500);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
paged.PageSize.Should().Be(500);
paged.TotalPages.Should().Be(1);
service.VerifyAll();
}
[Fact]
public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians()
{
var service = new Mock<IVendorService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync(
1,
10,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
{
Items = new List<VendorDirectoryItemDTO>
{
new()
{
Id = 9,
CompanyId = 3,
CompanyName = "Gateway Plumbing",
Email = "company@gatewayplumbing.example",
TotalJobs = 6,
Technicians = new List<VendorDirectoryTechnicianDTO>
{
new() { Id = 7, ContactName = "Tech A", Email = "tech.a@example.com" },
new() { Id = 9, ContactName = "Tech B", Email = "tech.b@example.com" }
}
}
},
TotalCount = 1,
Page = 1,
PageSize = 10
});
var result = await NewController(service).GetVendorDirectoryList(search: "clayton", cancellationToken: token);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var paged = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
var company = ((System.Collections.IEnumerable)paged.Data!).Cast<object>().Single();
// SH-281: company row with nested technicians; top-level Email stays the
// company email while technician emails stay on the nested rows.
company.GetType().GetProperty("Email")!.GetValue(company).Should().Be("company@gatewayplumbing.example");
var technicians = (System.Collections.IEnumerable)company.GetType().GetProperty("Technicians")!.GetValue(company)!;
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Id")!.GetValue(t))
.Should().BeEquivalentTo(new[] { 7, 9 });
technicians.Cast<object>().Select(t => t.GetType().GetProperty("Email")!.GetValue(t))
.Should().BeEquivalentTo(new[] { "tech.a@example.com", "tech.b@example.com" });
service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync(
1,
10,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes()
{
var legacyRoutes = typeof(VendorController)
.GetMethod(nameof(VendorController.GetVendorList))!
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
.Cast<HttpGetAttribute>()
.Select(attribute => attribute.Template);
var companyRoutes = typeof(VendorController)
.GetMethod(nameof(VendorController.GetVendorDirectoryList))!
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
.Cast<HttpGetAttribute>()
.Select(attribute => attribute.Template);
legacyRoutes.Should().BeEquivalentTo("GetVendorList");
companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList");
}
[Fact]

View file

@ -152,7 +152,7 @@ public class VendorServiceTests
}
[Fact]
public async Task GetVendorDirectoryPaged_ForwardsCancellationToken()
public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians()
{
var data = new Mock<IVendorDataService>();
using var cancellation = new CancellationTokenSource();
@ -175,6 +175,7 @@ public class VendorServiceTests
CompanyId = 3,
CompanyName = "Gateway Plumbing",
ContactName = "Primary Tech",
Email = "company@gatewayplumbing.example",
TotalJobs = 4,
Technicians = new[]
{
@ -190,14 +191,16 @@ public class VendorServiceTests
}
}.AsEnumerable().ToList(), 1));
var result = await NewService(data).GetVendorDirectoryPagedAsync(
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1,
12,
"clayton",
true,
cancellationToken: token);
result.Items.Single().Technicians.Should().ContainSingle()
var group = result.Items.Single();
group.Email.Should().Be("company@gatewayplumbing.example");
group.Technicians.Should().ContainSingle()
.Which.Email.Should().Be("primary@example.com");
data.Verify(x => x.GetCompanyDirectoryPagedAsync(
@ -213,7 +216,7 @@ public class VendorServiceTests
}
[Fact]
public async Task GetVendorDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
public async Task GetVendorCompanyDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
@ -228,13 +231,62 @@ public class VendorServiceTests
It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
var result = await NewService(data).GetVendorDirectoryPagedAsync(0, 500);
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(0, 500);
result.Page.Should().Be(1);
result.PageSize.Should().Be(100);
data.VerifyAll();
}
[Fact]
public async Task GetVendorTechnicianDirectoryPaged_ForwardsRawPaginationAndCancellationToken()
{
var data = new Mock<IVendorDataService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
data.Setup(x => x.GetDirectoryPagedAsync(
2,
500,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync((new[]
{
new VendorDirectoryRow { VendorId = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
new VendorDirectoryRow { VendorId = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
}.AsEnumerable().ToList(), 2));
var result = await NewService(data).GetVendorTechnicianDirectoryPagedAsync(
2,
500,
"clayton",
true,
cancellationToken: token);
// Legacy pagination semantics: page and pageSize (including 500) pass through
// unclamped, and same-company technicians stay as distinct rows.
result.Page.Should().Be(2);
result.PageSize.Should().Be(500);
result.Items.Should().HaveCount(2);
result.Items.Select(v => v.Id).Should().BeEquivalentTo(new[] { 7, 9 });
result.Items.Should().OnlyContain(v => v.Technicians.Count == 0);
data.Verify(x => x.GetDirectoryPagedAsync(
2,
500,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
}
[Fact]
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
{

View file

@ -26,6 +26,9 @@ namespace Api.SeaHavenIndustries.Controllers
_logger = logger;
}
// SH-198 legacy per-technician directory contract. Existing consumers depend
// on one row per technician (no grouped Technicians) and on raw pagination
// semantics, including pageSize values above the grouped-directory bound.
[HttpGet("GetVendorList")]
public async Task<IActionResult> GetVendorList(
string? search = "",
@ -38,7 +41,65 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] string[]? jobBuckets = null,
CancellationToken cancellationToken = default)
{
var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync(
var pagedResult = await _vendorService.GetVendorTechnicianDirectoryPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets,
cancellationToken);
var data = pagedResult.Items.Select(v => new
{
v.Id,
CompanyName = v.CompanyName,
v.CompanyId,
v.ContactName,
v.Email,
v.Phone,
v.CompanyPhone,
v.PreferredContact,
v.Address,
v.City,
v.State,
Zip = v.Zipcode,
v.TradeSpecialties,
v.GoogleMapsUrl,
v.Notes,
v.IsActive,
v.TotalJobs
});
return Ok(new Pagination_DTO
{
Data = data,
PageNumber = page,
PageSize = pageSize,
TotalCount = pagedResult.TotalCount,
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize)
});
}
// SH-281 company-grouped directory: one row per company with the full
// status-eligible technician roster nested under Technicians and bounded
// page sizes. Top-level Email is the company email; technician emails stay
// on the nested rows.
[HttpGet("GetVendorDirectoryList")]
public async Task<IActionResult> GetVendorDirectoryList(
string? search = "",
int page = 1,
int pageSize = 10,
bool? isActive = true,
[FromQuery] string[]? companies = null,
[FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null,
CancellationToken cancellationToken = default)
{
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
page,
pageSize,
search,

View file

@ -35,8 +35,9 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null);
// Compatibility path for existing per-technician consumers and behavior
// tests. Vendor/GetVendorList uses the company-grouped path below.
// Compatibility path for existing per-technician consumers (GetVendorList)
// and behavior tests. GetVendorDirectoryList uses the company-grouped path
// below.
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
int page,
int pageSize,

View file

@ -21,8 +21,11 @@ namespace SeaHaven.Services.DTOs
public bool IsActive { get; set; }
}
// SH-281 company directory row. Id is the primary technician's vendor id (null
// for company-only groups that have zero technicians); CompanyId is populated for
// Shared directory row. On the SH-198 legacy per-technician path (GetVendorList)
// Id is the technician's own vendor id and Technicians stays empty — the legacy
// response shape does not project it. On the SH-281 company-grouped path
// (GetVendorDirectoryList) Id is the primary technician's vendor id (null for
// company-only groups that have zero technicians); CompanyId is populated for
// linked and company-only groups. Technicians carries the full status-eligible
// roster of the company group.
public class VendorDirectoryItemDTO

View file

@ -95,7 +95,63 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
// SH-198 legacy per-technician directory contract (GetVendorList): raw
// pagination semantics are part of the compatibility surface, so pageSize
// is forwarded unclamped.
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
CancellationToken cancellationToken = default)
{
var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets,
cancellationToken);
return new PagedResult<VendorDirectoryItemDTO>
{
Items = items.Select(MapToDirectoryItem).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new()
{
Id = row.VendorId,
CompanyId = row.CompanyId == 0 ? null : row.CompanyId,
CompanyName = row.CompanyName,
ContactName = row.ContactName,
Email = row.Email,
Phone = row.Phone,
CompanyPhone = row.CompanyPhone,
PreferredContact = row.PreferredContact,
Address = row.Address,
City = row.City,
State = row.State,
Zipcode = row.Zip,
TradeSpecialties = row.TradeSpecialties,
GoogleMapsUrl = row.GoogleMapsUrl,
Notes = row.Notes,
IsActive = row.IsActive,
TotalJobs = row.TotalJobs
};
// SH-281 company-grouped directory (GetVendorDirectoryList).
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,

View file

@ -17,8 +17,22 @@ namespace SeaHaven.Services.Interfaces
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null);
// SH-198 vendor directory: per-technician rows UNION company-only rows.
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
// SH-198 legacy vendor directory contract backing GetVendorList: one row per
// technician (per-technician rows UNION company-only rows), raw pagination.
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorTechnicianDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
CancellationToken cancellationToken = default);
// SH-281 company directory backing GetVendorDirectoryList: company-grouped
// rows with nested technicians and bounded page sizes.
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorCompanyDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,