shoc-backend/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs

335 lines
16 KiB
C#
Raw Normal View History

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<IVendorCompanyRosterService> service, string? userId = "42")
{
var controller = new VendorCompanyRosterController(service.Object, Mock.Of<ILogger<VendorCompanyRosterController>>());
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<VendorRosterTechnicianDTO> { new() { Id = 1, ContactName = "Riley" } }
};
[Fact]
public async Task Get_Unauthenticated_Returns401()
{
var service = new Mock<IVendorCompanyRosterService>();
var controller = NewController(service, userId: null);
var result = await controller.Get(vendorId: 1, companyId: null, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Get_RosterFound_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(1, null, "42", It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
ok.Value.Should().BeEquivalentTo(SampleRoster());
}
[Fact]
public async Task Get_RosterMissing_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorRosterDTO?)null);
var result = await NewController(service).Get(vendorId: 99, companyId: null, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Get_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task Create_Unauthenticated_Returns401()
{
var service = new Mock<IVendorCompanyRosterService>();
var controller = NewController(service, userId: null);
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
}
[Fact]
public async Task Create_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>();
}
[Fact]
public async Task Create_DuplicateName_ReturnsStable409()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ConflictObjectResult>().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<IVendorCompanyRosterService>();
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<UnauthorizedObjectResult>();
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>();
}
[Fact]
public async Task Reconcile_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<NotFoundObjectResult>();
}
[Fact]
public async Task Reconcile_OpenWorkOrderConflict_ReturnsStable409()
{
var blocked = new List<LinkedWorkOrderInfo>
{
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
};
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
}
[Fact]
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ConflictObjectResult>().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<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("secret stack details"));
var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None);
var serverError = result.Should().BeOfType<ObjectResult>().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<RosterTechnicianInputDTO> { new() { ContactName = "Riley", IsActive = true } }
};
[Fact]
public async Task Patch_Unauthenticated_Returns401()
{
var service = new Mock<IVendorCompanyRosterService>();
var controller = NewController(service, userId: null);
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Patch_AddsTechnicians_Returns200WithRoster()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
ok.Value.Should().BeEquivalentTo(SampleRoster());
}
[Fact]
public async Task Patch_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task Patch_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().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<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.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<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
}
}