mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
* feat(vendors): add company roster management * fix(security): remove request-controlled write guards * fix(vendors): synchronize roster company fields * fix(vendors): source facets from companies
216 lines
9.6 KiB
C#
216 lines
9.6 KiB
C#
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<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<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<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 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<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<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<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<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<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");
|
|
}
|
|
}
|