mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
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.
521 lines
20 KiB
C#
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>();
|
|
}
|
|
}
|