From 35b647c0f21e2b726825e8a499fee4c18f1a41a2 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 14 Sep 2026 15:24:55 -0300 Subject: [PATCH] fix(vendors): preserve technician list contract --- .../VendorControllerTests.cs | 173 +++++++++++++++++- .../VendorServiceTests.cs | 62 ++++++- .../Controllers/VendorController.cs | 63 ++++++- .../Interfaces/IVendorDataService.cs | 5 +- SeaHaven.Services/DTOs/VendorDTOs.cs | 7 +- .../Implementation/VendorService.cs | 58 +++++- .../Interfaces/IVendorService.cs | 18 +- 7 files changed, 371 insertions(+), 15 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index e0df2a9..99b7d1d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -39,7 +39,7 @@ public class VendorControllerTests var service = new Mock(); 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?>(), It.IsAny?>(), token), Times.Once); + service.Verify(x => x.GetVendorCompanyDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetVendorList_ReturnsIndividualTechnicianRowsWithoutGroupedTechnicians() + { + var service = new Mock(); + service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List + { + 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().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + var rows = ((System.Collections.IEnumerable)paged.Data!).Cast().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(); + service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( + 1, + 500, + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(new PagedResult { TotalCount = 420, Page = 1, PageSize = 500 }); + + var result = await NewController(service).GetVendorList(page: 1, pageSize: 500); + + var ok = result.Should().BeOfType().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + paged.PageSize.Should().Be(500); + paged.TotalPages.Should().Be(1); + service.VerifyAll(); + } + + [Fact] + public async Task GetVendorDirectoryList_DelegatesToCompanyDirectoryServiceWithNestedTechnicians() + { + var service = new Mock(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + service.Setup(x => x.GetVendorCompanyDirectoryPagedAsync( + 1, + 10, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token)) + .ReturnsAsync(new PagedResult + { + Items = new List + { + new() + { + Id = 9, + CompanyId = 3, + CompanyName = "Gateway Plumbing", + Email = "company@gatewayplumbing.example", + TotalJobs = 6, + Technicians = new List + { + 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().Subject; + var paged = ok.Value.Should().BeOfType().Subject; + var company = ((System.Collections.IEnumerable)paged.Data!).Cast().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().Select(t => t.GetType().GetProperty("Id")!.GetValue(t)) + .Should().BeEquivalentTo(new[] { 7, 9 }); + technicians.Cast().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?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token), Times.Once); + service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny()), Times.Never); + } + + [Fact] + public void VendorDirectoryEndpoints_AdvertiseDistinctCompatibilityAndCompanyRoutes() + { + var legacyRoutes = typeof(VendorController) + .GetMethod(nameof(VendorController.GetVendorList))! + .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + var companyRoutes = typeof(VendorController) + .GetMethod(nameof(VendorController.GetVendorDirectoryList))! + .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + + legacyRoutes.Should().BeEquivalentTo("GetVendorList"); + companyRoutes.Should().BeEquivalentTo("GetVendorDirectoryList"); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index f769c5e..a8e996d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -152,7 +152,7 @@ public class VendorServiceTests } [Fact] - public async Task GetVendorDirectoryPaged_ForwardsCancellationToken() + public async Task GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians() { var data = new Mock(); 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(); data.Setup(x => x.GetCompanyDirectoryPagedAsync( @@ -228,13 +231,62 @@ public class VendorServiceTests It.IsAny())) .ReturnsAsync((new List(), 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(); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + data.Setup(x => x.GetDirectoryPagedAsync( + 2, + 500, + "clayton", + true, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + 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?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + token), Times.Once); + } + [Fact] public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 628c25c..fc42b88 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -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 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 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, diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 7725b6d..2e08912 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -35,8 +35,9 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyCollection? locations = null, IReadOnlyCollection? 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 Items, int TotalCount)> GetDirectoryPagedAsync( int page, int pageSize, diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index afdd03b..12fb5b8 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -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 diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index d1b14e2..5e4ce40 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -95,7 +95,63 @@ namespace SeaHaven.Services.Implementation }; } - public async Task> 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> GetVendorTechnicianDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null, + CancellationToken cancellationToken = default) + { + var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets, + cancellationToken); + + return new PagedResult + { + 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> GetVendorCompanyDirectoryPagedAsync( int page, int pageSize, string? search = null, diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 68e8ae9..74782af 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -17,8 +17,22 @@ namespace SeaHaven.Services.Interfaces IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null); - // SH-198 vendor directory: per-technician rows UNION company-only rows. - Task> GetVendorDirectoryPagedAsync( + // SH-198 legacy vendor directory contract backing GetVendorList: one row per + // technician (per-technician rows UNION company-only rows), raw pagination. + Task> GetVendorTechnicianDirectoryPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null, + CancellationToken cancellationToken = default); + + // SH-281 company directory backing GetVendorDirectoryList: company-grouped + // rows with nested technicians and bounded page sizes. + Task> GetVendorCompanyDirectoryPagedAsync( int page, int pageSize, string? search = null,