shoc-backend/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Alexandre Brandizzi 7a0856ddf7 feat(vendors): confirm-to-deactivate with open work orders (SH-254)
SH-44 and SH-82 both left "blocks, or requires explicit confirmation" to
be decided with the team, and the implementation took the blocking
branch. SH-254 settles it the other way: the approved design offers
"Deactivate anyway" beside the list of open work orders.

Deactivation with open work orders is now permitted, but only when the
caller says it has shown them: ConfirmOpenWorkOrders on the update DTO
and a confirmOpenWorkOrders query parameter on the delete route. Absent
the flag the existing guard still throws, so nothing deactivates by
accident and no caller loses the check by omission.

confirmOpenWorkOrders is a required parameter on DeleteVendorAsync
rather than an optional one, so every call site states its intent.
2026-08-19 13:31:16 -03:00

349 lines
12 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.GetVendorDirectoryPagedAsync(
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.GetVendorDirectoryPagedAsync(
1,
10,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
}
[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>();
}
}