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 System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class UpliftControllerTests { private static UpliftController NewController(Mock service, params string[] roles) { var controller = new UpliftController(service.Object, Mock.Of>()); var claims = new List { new(ClaimTypes.NameIdentifier, "42") }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")) } }; return controller; } [Fact] public async Task List_ReturnsPagedResult() { var service = new Mock(); var dto = new UpliftListResultDTO { Total = 1, Page = 1, PageSize = 25, Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } } }; service.Setup(x => x.ListAsync(It.IsAny(), "Pending", null, 1, 25, It.IsAny())) .ReturnsAsync(dto); var controller = NewController(service); var result = await controller.List(); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; envelope.Status.Should().Be("Success"); } [Fact] public async Task List_PassesStatusAndTierFiltersToService() { var service = new Mock(); service.Setup(x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny())) .ReturnsAsync(new UpliftListResultDTO()); var controller = NewController(service); await controller.List("Approved", 2, 3, 50); service.Verify( x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny()), Times.Once); } [Fact] public async Task List_ReturnsQueueContractFieldsInEnvelope() { var service = new Mock(); var item = new UpliftListItemDTO { Id = 7, WorkOrderId = 11, WorkOrderNumber = "WO-77", WorkOrderSite = "SITE-EAST", WorkOrderService = "HVAC", RequestedByName = "Gateway", WorkOrderAutoApprovedTotal = 150m, WorkOrderAdminApprovedTotal = 300m, WorkOrderApprovedExposureTotal = 450m }; service.Setup(x => x.ListAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new UpliftListResultDTO { Total = 1, Page = 1, PageSize = 25, Items = new[] { item } }); var controller = NewController(service); var result = await controller.List(); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; var data = envelope.Data as UpliftListResultDTO; var returned = data!.Items.Should().ContainSingle().Subject; returned.WorkOrderNumber.Should().Be("WO-77"); returned.WorkOrderSite.Should().Be("SITE-EAST"); returned.WorkOrderService.Should().Be("HVAC"); returned.RequestedByName.Should().Be("Gateway"); returned.WorkOrderApprovedExposureTotal.Should().Be(450m); } [Fact] public async Task Approve_NotFound_Returns404() { var service = new Mock(); service.Setup(x => x.ApproveAsync(It.IsAny(), 99, null, It.IsAny())) .ThrowsAsync(new KeyNotFoundException("not found")); var controller = NewController(service); var result = await controller.Approve(99, null); var nf = result.Should().BeOfType().Subject; nf.Value.Should().BeOfType(); } [Fact] public async Task Revoke_WithReason_DelegatesToService() { var service = new Mock(); service.Setup(x => x.RevokeAsync( It.IsAny(), 7, "Policy change", It.IsAny())) .ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" }); var controller = NewController(service, "Admin"); var result = await controller.Revoke( 7, new UpliftController.DecisionRequest { Note = "Policy change" }); result.Should().BeOfType(); service.Verify(x => x.RevokeAsync( It.IsAny(), 7, "Policy change", It.IsAny()), Times.Once); } [Fact] public async Task Revoke_WithoutReason_ReturnsBadRequest() { var service = new Mock(); service.Setup(x => x.RevokeAsync( It.IsAny(), 7, string.Empty, It.IsAny())) .ThrowsAsync(new InvalidOperationException("reason required")); var controller = NewController(service, "Admin"); var result = await controller.Revoke(7, null); result.Should().BeOfType(); } [Fact] public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() { var service = new Mock(); service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) .ThrowsAsync(new UpliftForbiddenException("SECRET-tier-restriction-detail")); var logger = new Mock>(); logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); var controller = new UpliftController(service.Object, logger.Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test")) } }; var result = await controller.Approve(5, null); var status = result.Should().BeOfType().Subject; status.StatusCode.Should().Be(403); var response = status.Value.Should().BeOfType().Subject; response.Message.Should().NotContain("SECRET"); response.Message.Should().Contain("reference"); logger.Verify( x => x.Log( LogLevel.Error, It.IsAny(), It.IsAny(), It.Is(ex => ex.Message.Contains("SECRET")), It.IsAny>()), Times.Once); } [Fact] public async Task Approve_ForbiddenTier_Returns403() { var service = new Mock(); service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) .ThrowsAsync(new UpliftForbiddenException("requires Tier 2")); var controller = NewController(service); var result = await controller.Approve(5, null); var status = result.Should().BeOfType().Subject; status.StatusCode.Should().Be(403); } [Fact] public async Task Approve_AlreadyDecided_Returns400() { var service = new Mock(); service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) .ThrowsAsync(new InvalidOperationException("already decided")); var controller = NewController(service); var result = await controller.Approve(5, null); result.Should().BeOfType(); } [Fact] public async Task Approve_Success_ReturnsApprovedResult() { var service = new Mock(); var approved = new UpliftApproveResultDTO { Id = 5, Status = "Approved", NTEAmount = 750m }; service.Setup(x => x.ApproveAsync(It.IsAny(), 5, "ok", It.IsAny())) .ReturnsAsync(approved); var controller = NewController(service); var result = await controller.Approve(5, new UpliftController.DecisionRequest { Note = "ok" }); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; var approveData = envelope.Data as UpliftApproveResultDTO; approveData.Should().Be(approved); } [Fact] public async Task Deny_WithoutNote_Returns400() { var service = new Mock(); service.Setup(x => x.DenyAsync(It.IsAny(), 5, null, It.IsAny())) .ThrowsAsync(new InvalidOperationException("A note is required when denying")); var controller = NewController(service); var result = await controller.Deny(5, null); result.Should().BeOfType(); } [Fact] public async Task Deny_Success_ReturnsDeniedResult() { var service = new Mock(); var denied = new UpliftDenyResultDTO { Id = 5, Status = "Denied" }; service.Setup(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny())) .ReturnsAsync(denied); var controller = NewController(service); var result = await controller.Deny(5, new UpliftController.DecisionRequest { Note = "too high" }); var ok = result.Should().BeOfType().Subject; service.Verify(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny()), Times.Once); } [Fact] public void CanApprove_DelegatesToService() { var service = new Mock(); service.Setup(x => x.CanApprove(It.IsAny(), 2)) .Returns(true); var controller = NewController(service, "Manager"); var result = controller.CanApprove(2); var ok = result.Should().BeOfType().Subject; service.Verify(x => x.CanApprove(It.Is(p => p.IsInRole("Manager")), 2), Times.Once); } [Fact] public async Task ListForDispatch_ReturnsItems() { var service = new Mock(); var items = new[] { new UpliftForDispatchDTO { Id = 1, DispatchId = 10 } }; service.Setup(x => x.ListForDispatchAsync(It.IsAny(), 10, It.IsAny())) .ReturnsAsync(items); var controller = NewController(service); var result = await controller.ListForDispatch(10); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; envelope.Status.Should().Be("Success"); } [Fact] public async Task DownloadEvidence_NotFound_Returns404() { var service = new Mock(); service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 99, It.IsAny())) .ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound()); var controller = NewController(service); var result = await controller.DownloadEvidence(99); result.Should().BeOfType(); } [Fact] public async Task DownloadEvidence_Locked_Returns423() { var service = new Mock(); service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) .ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked()); var controller = NewController(service); var result = await controller.DownloadEvidence(5); var status = result.Should().BeOfType().Subject; status.StatusCode.Should().Be(StatusCodes.Status423Locked); var response = status.Value.Should().BeOfType().Subject; response.Message.Should().NotContain("scan", "the locked message must not disclose internal state"); } [Fact] public async Task DownloadEvidence_Available_ReturnsFile() { var service = new Mock(); service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) .ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf")); var controller = NewController(service); var result = await controller.DownloadEvidence(5); result.Should().BeOfType(); } [Fact] public async Task DownloadEvidence_Forbidden_ReturnsSanitized403() { var service = new Mock(); service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny(), 5, It.IsAny())) .ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy")); var controller = NewController(service); var result = await controller.DownloadEvidence(5); var forbidden = result.Should().BeOfType().Subject; forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden); var response = forbidden.Value.Should().BeOfType().Subject; response.Message.Should().NotContain("SECRET"); response.Message.Should().Contain("reference"); } [Fact] public async Task RequestChanges_InvalidOperation_ReturnsSanitized400() { var service = new Mock(); service.Setup(x => x.RequestChangesAsync(It.IsAny(), 5, "note", It.IsAny())) .ThrowsAsync(new InvalidOperationException("SECRET-internal-state")); var controller = NewController(service); var result = await controller.RequestChanges(5, new UpliftController.DecisionRequest { Note = "note" }); var bad = result.Should().BeOfType().Subject; var resp = bad.Value.Should().BeOfType().Subject; resp.Message.Should().NotContain("SECRET"); resp.Message.Should().Contain("reference"); } }