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 service, string? token = "valid-token") { var controller = new VendorPortalController(service.Object, Mock.Of>()); 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(); var controller = NewController(service, token: null); var result = await controller.Session(); result.Should().BeOfType(); } [Fact] public async Task Session_InvalidToken_ReturnsUnauthorized() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny())) .ReturnsAsync((VendorPortalSession?)null); var controller = NewController(service, token: "bad"); var result = await controller.Session(); result.Should().BeOfType(); } [Fact] public async Task Session_ValidToken_ReturnsSessionData() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny())) .ReturnsAsync(Session); var controller = NewController(service); var result = await controller.Session(); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().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(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } }; service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny())) .ReturnsAsync(dispatches); var controller = NewController(service); var result = await controller.ListDispatches("Sent"); var ok = result.Should().BeOfType().Subject; service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny()), Times.Once); } [Fact] public async Task GetDispatch_NotFound_Returns404() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny())) .ReturnsAsync((VendorDispatchDetailDTO?)null); var controller = NewController(service); var result = await controller.GetDispatch(99); result.Should().BeOfType(); } [Fact] public async Task Accept_KeyNotFound_Returns404() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny())) .ThrowsAsync(new KeyNotFoundException()); var controller = NewController(service); var result = await controller.Accept(99); result.Should().BeOfType(); } [Fact] public async Task Accept_InvalidOperation_Returns400() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) .ThrowsAsync(new InvalidOperationException("Cannot accept")); var controller = NewController(service); var result = await controller.Accept(10); var bad = result.Should().BeOfType().Subject; var resp = bad.Value.Should().BeOfType().Subject; resp.Message.Should().NotContain("Cannot accept"); resp.Message.Should().Contain("reference"); } [Fact] public async Task Accept_Success_ReturnsAcknowledgedResult() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" }; service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) .ReturnsAsync(acceptResult); var controller = NewController(service); var result = await controller.Accept(10); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; var acceptData = envelope.Data as AcceptDispatchResultDTO; acceptData.Should().Be(acceptResult); } [Fact] public async Task Refuse_KeyNotFound_Returns404() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.RefuseDispatchAsync(Session, 99, It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException()); var controller = NewController(service); var result = await controller.Refuse(99, new VendorPortalController.RefuseRequest { Reason = null }); result.Should().BeOfType(); } [Fact] public async Task Refuse_InvalidOperation_Returns400WithoutInternalDetail() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.RefuseDispatchAsync(Session, 10, "no", It.IsAny())) .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().Subject; var resp = bad.Value.Should().BeOfType().Subject; resp.Message.Should().NotContain("Cannot refuse"); resp.Message.Should().Contain("reference"); } [Fact] public async Task Refuse_Success_ReturnsRefusedResult() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .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())) .ReturnsAsync(refuseResult); var controller = NewController(service); var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "Too far" }); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; var data = envelope.Data as RefuseDispatchResultDTO; data.Should().Be(refuseResult); service.Verify(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny()), Times.Once); } [Fact] public void RefuseEndpoint_AdvertisesDualCompatibilityRoutes() { var routes = typeof(VendorPortalController) .GetMethod(nameof(VendorPortalController.Refuse))! .GetCustomAttributes(typeof(HttpPostAttribute), inherit: false) .Cast() .Select(attribute => attribute.Template); routes.Should().BeEquivalentTo("dispatches/{id:int}/refuse"); } [Fact] public async Task ChangeStatus_ForbiddenTransition_Returns400() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny())) .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().Subject; var resp = bad.Value.Should().BeOfType().Subject; resp.Message.Should().NotContain("Transition"); resp.Message.Should().Contain("reference"); } [Fact] public async Task RequestCancel_Success_ReturnsSuccessWithMessage() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); var controller = NewController(service); var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" }); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().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()), Times.Once); } [Fact] public async Task UpdateChecklistItem_LockedDispatch_Returns400() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny())) .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(); } [Fact] public async Task AddSignoff_DuplicateSignoff_Returns400() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny())) .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(); } [Fact] public async Task RequestUplift_NTEBelowCurrent_Returns400() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny(), It.IsAny(), It.IsAny())) .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(); } [Fact] public async Task RequestUplift_ConcurrentActiveRequest_ReturnsConflict() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.RequestUpliftAsync( Session, 10, 1500m, "Need more parts", It.IsAny(), It.IsAny(), It.IsAny())) .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().Subject; conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.Value.Should().NotBeNull(); } [Fact] public async Task RequestUplift_Success_ReturnsResult() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .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(), It.IsAny(), It.IsAny())) .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().Subject; var envelope = ok.Value.Should().BeOfType().Subject; var upliftData = envelope.Data as UpliftRequestResultDTO; upliftData.Should().Be(upliftResult); } [Fact] public async Task CancelUpliftRequest_NonPending_Returns400() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) .ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request")); var controller = NewController(service); var result = await controller.CancelUpliftRequest(10, 77); result.Should().BeOfType(); } [Fact] public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" }; service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) .ReturnsAsync(cancelResult); var controller = NewController(service); var result = await controller.CancelUpliftRequest(10, 77); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; var cancelData = envelope.Data as CancelUpliftResultDTO; cancelData.Should().Be(cancelResult); } [Fact] public async Task AddComment_EmptyText_Returns400() { var service = new Mock(); service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Session); service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny())) .ThrowsAsync(new InvalidOperationException("Comment cannot be empty")); var controller = NewController(service); var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" }); result.Should().BeOfType(); } }