shoc-backend/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Alexandre Brandizzi 8515676f4d feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
2026-09-16 11:34:45 -03:00

521 lines
20 KiB
C#

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<IVendorService> service)
{
var controller = new VendorController(service.Object, Mock.Of<ILogger<VendorController>>());
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<IVendorService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
service.Setup(x => x.GetVendorTechnicianDirectoryPagedAsync(
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>());
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
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.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<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>?>(),
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>?>(),
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]
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
.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<OkObjectResult>();
service.Verify(x => x.UpdateVendorAsync(
8,
It.Is<UpdateVendorDTO>(dto => dto.IsActive == null),
"42"), Times.Once);
}
[Fact]
public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
.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<UpdateVendorDTO>(dto => dto.IsActive == false),
"42"), Times.Once);
}
[Fact]
public async Task Create_WithoutIsActive_DefaultsToActive()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.CreateVendorAsync(It.IsAny<CreateVendorDTO>(), It.IsAny<string>()))
.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<CreateVendorDTO>(dto => dto.IsActive == true),
"42"), Times.Once);
}
[Fact]
public async Task GetFacets_DelegatesToServiceAndReturnsOk()
{
var service = new Mock<IVendorService>();
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<CancellationToken>()))
.ReturnsAsync(facets);
var controller = NewController(service);
var result = await controller.GetFacets(true);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(facets);
service.Verify(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetDropdown_DelegatesToServiceAndReturnsOk()
{
var service = new Mock<IVendorService>();
var items = new[]
{
new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 }
};
service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
var controller = NewController(service);
var result = await controller.GetDropdown("Plumbing", "60601");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(items);
}
[Fact]
public async Task GetPortalToken_ReturnsTokenDtoFromService()
{
var service = new Mock<IVendorService>();
var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" };
service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(token);
var controller = NewController(service);
var result = await controller.GetPortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(token);
}
[Fact]
public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorPortalTokenDTO?)null);
var controller = NewController(service);
var result = await controller.GetPortalToken(99);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task RotatePortalToken_ReturnsRotatedToken()
{
var service = new Mock<IVendorService>();
var token = new VendorPortalTokenDTO { Token = "new-token" };
service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(token);
var controller = NewController(service);
var result = await controller.RotatePortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(token);
}
[Fact]
public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.RevokePortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().NotBeNull();
}
[Fact]
public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var controller = NewController(service);
var result = await controller.RevokePortalToken(99);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task Delete_WhenDeactivationBlocked_ReturnsSanitizedConflictWithOpenWorkOrdersAndLogs()
{
var openWorkOrders = new List<LinkedWorkOrderDTO> { new() { WorkOrderId = 7 } };
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<bool>()))
.ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders));
var logger = new Mock<ILogger<VendorController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).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<ConflictObjectResult>().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<LinkedWorkOrderDTO>)value.GetType().GetProperty("OpenWorkOrders")!.GetValue(value)!;
returnedOpenWorkOrders.Should().HaveCount(1);
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.Is<Exception>(ex => ex.Message.Contains("SECRET")),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task Delete_MapsDeactivationBlocked_ToConflict()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<bool>()))
.ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List<LinkedWorkOrderDTO>()));
var controller = NewController(service);
var result = await controller.Delete(5);
result.Should().BeOfType<ConflictObjectResult>();
}
[Fact]
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<bool>()))
.ThrowsAsync(new InvalidOperationException());
var controller = NewController(service);
var result = await controller.Delete(5);
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
nf.Value.Should().BeOfType<Response>();
}
[Fact]
public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetVendorByIdAsync(99))
.ReturnsAsync((VendorDTO?)null);
var controller = NewController(service);
var result = await controller.GetVendorById(99, null);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Update_UnauthenticatedUser_ReturnsUnauthorized()
{
var service = new Mock<IVendorService>();
var controller = new VendorController(service.Object, Mock.Of<ILogger<VendorController>>());
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<UnauthorizedObjectResult>();
}
}