using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using FluentValidation; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class VendorCompanyRosterControllerTests { private static VendorCompanyRosterController NewController(Mock service, string? userId = "42") { var controller = new VendorCompanyRosterController(service.Object, Mock.Of>()); ClaimsIdentity identity; if (userId == null) identity = new ClaimsIdentity(); else identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "Test"); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; return controller; } private static VendorRosterDTO SampleRoster() => new() { CompanyId = 7, Name = "Acme", Email = "acme@example.com", RowVersion = "AAAAAAAAD8I=", Technicians = new List { new() { Id = 1, ContactName = "Riley" } } }; [Fact] public async Task Get_Unauthenticated_Returns401() { var service = new Mock(); var controller = NewController(service, userId: null); var result = await controller.Get(vendorId: 1, companyId: null, CancellationToken.None); result.Should().BeOfType(); service.Verify(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Get_RosterFound_Returns200() { var service = new Mock(); service.Setup(x => x.GetRosterAsync(1, null, "42", It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None); var ok = result.Should().BeOfType().Subject; ok.StatusCode.Should().Be(StatusCodes.Status200OK); ok.Value.Should().BeEquivalentTo(SampleRoster()); } [Fact] public async Task Get_RosterMissing_Returns404() { var service = new Mock(); service.Setup(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) .ReturnsAsync((VendorRosterDTO?)null); var result = await NewController(service).Get(vendorId: 99, companyId: null, CancellationToken.None); result.Should().BeOfType(); } [Fact] public async Task Get_ValidationException_Returns400() { var service = new Mock(); service.Setup(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) .ThrowsAsync(new ValidationException("A vendor id or company id is required.")); var result = await NewController(service).Get(vendorId: null, companyId: null, CancellationToken.None); var bad = result.Should().BeOfType().Subject; bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest); } [Fact] public async Task Create_Unauthenticated_Returns401() { var service = new Mock(); var controller = NewController(service, userId: null); var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); service.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_Returns200() { var service = new Mock(); service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; ok.StatusCode.Should().Be(StatusCodes.Status200OK); } [Fact] public async Task Create_ValidationException_Returns400() { var service = new Mock(); service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("At least one company phone or email is required.")); var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None); result.Should().BeOfType(); } [Fact] public async Task Create_DuplicateName_ReturnsStable409() { var service = new Mock(); service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterDuplicateNameException( "database detail", new InvalidOperationException("IX_VendorCompanies_NormalizedName"))); var result = await NewController(service).Create( new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); var conflict = result.Should().BeOfType().Subject; conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); var json = System.Text.Json.JsonSerializer.Serialize(conflict.Value); json.Should().Contain("duplicate_vendor_company_name"); json.Should().Contain("Another vendor company already uses that name."); json.Should().NotContain("IX_VendorCompanies_NormalizedName"); json.Should().NotContain("database detail"); } [Fact] public async Task Reconcile_Unauthenticated_Returns401() { var service = new Mock(); var controller = NewController(service, userId: null); var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); service.Verify(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_ValidationException_Returns400() { var service = new Mock(); service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed.")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); } [Fact] public async Task Reconcile_CompanyNotFound_Returns404() { var service = new Mock(); service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); result.Should().BeOfType(); } [Fact] public async Task Reconcile_OpenWorkOrderConflict_ReturnsStable409() { var blocked = new List { new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled } }; var service = new Mock(); service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorRosterConflictException("blocked", blocked)); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); var conflict = result.Should().BeOfType().Subject; conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); } [Fact] public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); service.Setup(x => x.ReconcileRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); var conflict = result.Should().BeOfType().Subject; conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); } [Fact] public async Task Get_UnexpectedException_ReturnsSanitized500() { var service = new Mock(); service.Setup(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) .ThrowsAsync(new InvalidOperationException("secret stack details")); var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None); var serverError = result.Should().BeOfType().Subject; serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); serverError.Value!.ToString()!.Should().NotContain("secret stack details"); } private static AddTechniciansVendorRosterDTO PatchDto() => new() { RowVersion = "AAAAAAAAD8I=", AddTechnicians = new List { new() { ContactName = "Riley", IsActive = true } } }; [Fact] public async Task Patch_Unauthenticated_Returns401() { var service = new Mock(); var controller = NewController(service, userId: null); var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None); result.Should().BeOfType(); service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Patch_AddsTechnicians_Returns200WithRoster() { var service = new Mock(); service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) .ReturnsAsync(SampleRoster()); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); var ok = result.Should().BeOfType().Subject; ok.StatusCode.Should().Be(StatusCodes.Status200OK); ok.Value.Should().BeEquivalentTo(SampleRoster()); } [Fact] public async Task Patch_ValidationException_Returns400() { var service = new Mock(); service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians.")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); var bad = result.Should().BeOfType().Subject; bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest); } [Fact] public async Task Patch_CompanyNotFound_Returns404() { var service = new Mock(); service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None); result.Should().BeOfType(); } [Fact] public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText() { var service = new Mock(); service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); var conflict = result.Should().BeOfType().Subject; conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); } [Fact] public async Task AreaAssignmentForbidden_Returns403OnEveryMutation() { var service = new Mock(); service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorAreaAssignmentForbiddenException()); var controller = NewController(service); var results = new[] { await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None), await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None), await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None) }; foreach (var result in results) result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny(), "42", controller.User, It.IsAny()), Times.Once); } }