mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
412 lines
18 KiB
C#
412 lines
18 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.Exceptions;
|
|
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 Refuse_KeyNotFound_Returns404()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.RefuseDispatchAsync(Session, 99, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new KeyNotFoundException());
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Refuse(99, new VendorPortalController.RefuseRequest { Reason = null });
|
|
|
|
result.Should().BeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refuse_InvalidOperation_Returns400WithoutInternalDetail()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
service.Setup(x => x.RefuseDispatchAsync(Session, 10, "no", It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("Cannot refuse a dispatch with status 'Acknowledged'"));
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "no" });
|
|
|
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
|
var resp = bad.Value.Should().BeOfType<Response>().Subject;
|
|
resp.Message.Should().NotContain("Cannot refuse");
|
|
resp.Message.Should().Contain("reference");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refuse_Success_ReturnsRefusedResult()
|
|
{
|
|
var service = new Mock<IVendorPortalService>();
|
|
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Session);
|
|
var refusedAt = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
|
var refuseResult = new RefuseDispatchResultDTO { Id = 10, Status = "Refused", RefusedAt = refusedAt };
|
|
service.Setup(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(refuseResult);
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "Too far" });
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
|
var data = envelope.Data as RefuseDispatchResultDTO;
|
|
data.Should().Be(refuseResult);
|
|
service.Verify(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefuseEndpoint_AdvertisesDualCompatibilityRoutes()
|
|
{
|
|
var routes = typeof(VendorPortalController)
|
|
.GetMethod(nameof(VendorPortalController.Refuse))!
|
|
.GetCustomAttributes(typeof(HttpPostAttribute), inherit: false)
|
|
.Cast<HttpPostAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
|
|
routes.Should().BeEquivalentTo("dispatches/{id:int}/refuse");
|
|
}
|
|
|
|
[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<string?>(), It.IsAny<int?>(), 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_ConcurrentActiveRequest_ReturnsConflict()
|
|
{
|
|
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,
|
|
1500m,
|
|
"Need more parts",
|
|
It.IsAny<string?>(),
|
|
It.IsAny<int?>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new UpliftConflictException());
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
|
|
{
|
|
RequestedNTE = 1500m,
|
|
Reason = "Need more parts",
|
|
});
|
|
|
|
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
|
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
|
conflict.Value.Should().NotBeNull();
|
|
}
|
|
|
|
[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<string?>(), It.IsAny<int?>(), 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>();
|
|
}
|
|
}
|