mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-02 21:33:54 +00:00
* refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
317 lines
13 KiB
C#
317 lines
13 KiB
C#
using Api.SeaHavenIndustries.Controllers;
|
|
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 Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public class VendorPortalControllerTests
|
|
{
|
|
private static VendorPortalController NewController(Mock<IVendorPortalService> service, string? token = "valid-token")
|
|
{
|
|
var controller = new VendorPortalController(service.Object, Mock.Of<ILogger<VendorPortalController>>());
|
|
var context = new DefaultHttpContext();
|
|
if (token != null)
|
|
context.Request.Headers["X-Vendor-Token"] = token;
|
|
controller.ControllerContext = new ControllerContext { HttpContext = context };
|
|
return controller;
|
|
}
|
|
|
|
private static readonly VendorPortalSession Session = new()
|
|
{
|
|
Id = 1,
|
|
CompanyName = "Test Co",
|
|
ContactName = "Sam",
|
|
Email = "sam@test.co",
|
|
Phone = "555-0100"
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Session_MissingToken_ReturnsUnauthorized()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
var controller = NewController(service, token: null);
|
|
|
|
var result = await controller.Session();
|
|
|
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Session_InvalidToken_ReturnsUnauthorized()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((VendorPortalSession?)null);
|
|
var controller = NewController(service, token: "bad");
|
|
|
|
var result = await controller.Session();
|
|
|
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Session_ValidToken_ReturnsSessionData()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Session();
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
|
envelope.Status.Should().Be("Success");
|
|
var sessionData = envelope.Data as VendorPortalSession;
|
|
sessionData.Should().Be(Session);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListDispatches_DelegatesToServiceWithStatus()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } };
|
|
service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(dispatches);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.ListDispatches("Sent");
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDispatch_NotFound_Returns404()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((VendorDispatchDetailDTO?)null);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.GetDispatch(99);
|
|
|
|
result.Should().BeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Accept_KeyNotFound_Returns404()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new KeyNotFoundException());
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Accept(99);
|
|
|
|
result.Should().BeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Accept_InvalidOperation_Returns400()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Cannot accept"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Accept(10);
|
|
|
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
|
var resp = bad.Value.Should().BeOfType<Response>().Subject;
|
|
resp.Message.Should().NotContain("Cannot accept");
|
|
resp.Message.Should().Contain("reference");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Accept_Success_ReturnsAcknowledgedResult()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" };
|
|
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(acceptResult);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Accept(10);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
|
var acceptData = envelope.Data as AcceptDispatchResultDTO;
|
|
acceptData.Should().Be(acceptResult);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChangeStatus_ForbiddenTransition_Returns400()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Transition not allowed"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.ChangeStatus(10, new VendorPortalController.ChangeStatusRequest { Status = "Completed" });
|
|
|
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
|
var resp = bad.Value.Should().BeOfType<Response>().Subject;
|
|
resp.Message.Should().NotContain("Transition");
|
|
resp.Message.Should().Contain("reference");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestCancel_Success_ReturnsSuccessWithMessage()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" });
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
|
envelope.Status.Should().Be("Success");
|
|
envelope.Message.Should().Be("Cancel request sent to dispatcher");
|
|
service.Verify(x => x.RequestCancelAsync(Session, 10, "Parts delay", It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateChecklistItem_LockedDispatch_Returns400()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Dispatch is locked"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.UpdateChecklistItem(10, 5, new VendorPortalController.ChecklistUpdateRequest { IsCompleted = true });
|
|
|
|
result.Should().BeOfType<BadRequestObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddSignoff_DuplicateSignoff_Returns400()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("A vendor signoff already exists"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest
|
|
{
|
|
SignoffType = "vendor",
|
|
Name = "Name",
|
|
Signature = "sig",
|
|
SignatureMethod = "drawn"
|
|
});
|
|
|
|
result.Should().BeOfType<BadRequestObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestUplift_NTEBelowCurrent_Returns400()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m });
|
|
|
|
result.Should().BeOfType<BadRequestObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestUplift_Success_ReturnsResult()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 };
|
|
service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(upliftResult);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
|
|
{
|
|
RequestedNTE = 500m,
|
|
Reason = "Need more parts"
|
|
});
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
|
var upliftData = envelope.Data as UpliftRequestResultDTO;
|
|
upliftData.Should().Be(upliftResult);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelUpliftRequest_NonPending_Returns400()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.CancelUpliftRequest(10, 77);
|
|
|
|
result.Should().BeOfType<BadRequestObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" };
|
|
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(cancelResult);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.CancelUpliftRequest(10, 77);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
|
var cancelData = envelope.Data as CancelUpliftResultDTO;
|
|
cancelData.Should().Be(cancelResult);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddComment_EmptyText_Returns400()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Comment cannot be empty"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" });
|
|
|
|
result.Should().BeOfType<BadRequestObjectResult>();
|
|
}
|
|
}
|