using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class VendorControllerTests { private static VendorController NewController(Mock service) { var controller = new VendorController(service.Object, Mock.Of>()); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test")) } }; return controller; } [Fact] public async Task GetVendorList_ForwardsRequestCancellationToken() { var service = new Mock(); using var cancellation = new CancellationTokenSource(); var token = cancellation.Token; service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync( 1, 10, "clayton", true, It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), token)) .ReturnsAsync(new PagedResult()); await NewController(service).GetVendorList(search: "clayton", cancellationToken: token); service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( 1, 10, "clayton", true, It.IsAny?>(), It.IsAny?>(), 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?>(), 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?>(), 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?>(), 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] public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus() { var service = new Mock(); service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new VendorDTO()); var controller = NewController(service); var model = new EditVendor_DTO { Id = 8, CompanyName = "Updated Name" }; var result = await controller.Update(8, model); result.Should().BeOfType(); service.Verify(x => x.UpdateVendorAsync( 8, It.Is(dto => dto.IsActive == null), "42"), Times.Once); } [Fact] public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged() { var service = new Mock(); service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new VendorDTO()); var controller = NewController(service); var model = new EditVendor_DTO { Id = 8, CompanyName = "Updated Name", IsActive = false }; await controller.Update(8, model); service.Verify(x => x.UpdateVendorAsync( 8, It.Is(dto => dto.IsActive == false), "42"), Times.Once); } [Fact] public async Task Create_WithoutIsActive_DefaultsToActive() { var service = new Mock(); service.Setup(x => x.CreateVendorAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new VendorDTO()); var controller = NewController(service); var model = new Vendor_DTO { CompanyName = "New Co" }; await controller.Create(model); service.Verify(x => x.CreateVendorAsync( It.Is(dto => dto.IsActive == true), "42"), Times.Once); } [Fact] public async Task GetFacets_DelegatesToServiceAndReturnsOk() { var service = new Mock(); var facets = new VendorFacetsDTO { Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } }, Trades = new[] { "Plumbing" }, Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } }, JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } } }; service.Setup(x => x.GetFacetsAsync(true, It.IsAny())) .ReturnsAsync(facets); var controller = NewController(service); var result = await controller.GetFacets(true); var ok = result.Should().BeOfType().Subject; ok.Value.Should().Be(facets); service.Verify(x => x.GetFacetsAsync(true, It.IsAny()), Times.Once); } [Fact] public async Task GetDropdown_DelegatesToServiceAndReturnsOk() { var service = new Mock(); var items = new[] { new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 } }; service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny())) .ReturnsAsync(items); var controller = NewController(service); var result = await controller.GetDropdown("Plumbing", "60601"); var ok = result.Should().BeOfType().Subject; ok.Value.Should().Be(items); } [Fact] public async Task GetPortalToken_ReturnsTokenDtoFromService() { var service = new Mock(); var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" }; service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny())) .ReturnsAsync(token); var controller = NewController(service); var result = await controller.GetPortalToken(5); var ok = result.Should().BeOfType().Subject; ok.Value.Should().Be(token); } [Fact] public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist() { var service = new Mock(); service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny())) .ReturnsAsync((VendorPortalTokenDTO?)null); var controller = NewController(service); var result = await controller.GetPortalToken(99); result.Should().BeOfType(); } [Fact] public async Task RotatePortalToken_ReturnsRotatedToken() { var service = new Mock(); var token = new VendorPortalTokenDTO { Token = "new-token" }; service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny())) .ReturnsAsync(token); var controller = NewController(service); var result = await controller.RotatePortalToken(5); var ok = result.Should().BeOfType().Subject; ok.Value.Should().Be(token); } [Fact] public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess() { var service = new Mock(); service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny())) .ReturnsAsync(true); var controller = NewController(service); var result = await controller.RevokePortalToken(5); var ok = result.Should().BeOfType().Subject; ok.Value.Should().NotBeNull(); } [Fact] public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist() { var service = new Mock(); service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny())) .ReturnsAsync(false); var controller = NewController(service); var result = await controller.RevokePortalToken(99); result.Should().BeOfType(); } [Fact] public async Task Delete_WhenDeactivationBlocked_ReturnsSanitizedConflictWithOpenWorkOrdersAndLogs() { var openWorkOrders = new List { new() { WorkOrderId = 7 } }; var service = new Mock(); service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders)); var logger = new Mock>(); logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); var controller = new VendorController(service.Object, logger.Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test")) } }; var result = await controller.Delete(5); var conflict = result.Should().BeOfType().Subject; var value = conflict.Value!; var message = (string)value.GetType().GetProperty("Message")!.GetValue(value)!; message.Should().NotContain("SECRET"); message.Should().Contain("reference"); var returnedOpenWorkOrders = (List)value.GetType().GetProperty("OpenWorkOrders")!.GetValue(value)!; returnedOpenWorkOrders.Should().HaveCount(1); logger.Verify( x => x.Log( LogLevel.Error, It.IsAny(), It.IsAny(), It.Is(ex => ex.Message.Contains("SECRET")), It.IsAny>()), Times.Once); } [Fact] public async Task Delete_MapsDeactivationBlocked_ToConflict() { var service = new Mock(); service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List())); var controller = NewController(service); var result = await controller.Delete(5); result.Should().BeOfType(); } [Fact] public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound() { var service = new Mock(); service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException()); var controller = NewController(service); var result = await controller.Delete(5); var nf = result.Should().BeOfType().Subject; nf.Value.Should().BeOfType(); } [Fact] public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull() { var service = new Mock(); service.Setup(x => x.GetVendorByIdAsync(99)) .ReturnsAsync((VendorDTO?)null); var controller = NewController(service); var result = await controller.GetVendorById(99, null); result.Should().BeOfType(); } [Fact] public async Task Update_UnauthenticatedUser_ReturnsUnauthorized() { var service = new Mock(); var controller = new VendorController(service.Object, Mock.Of>()); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() } }; var result = await controller.Update(5, new EditVendor_DTO { Id = 5, CompanyName = "X" }); result.Should().BeOfType(); } }