using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class WorkOrderMediaControllerTests { private static WorkOrderMediaController CreateController( Mock? service = null, Mock? storage = null) { var controller = new WorkOrderMediaController( (service ?? new Mock()).Object, (storage ?? new Mock()).Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; return controller; } [Fact] public void AddMedia_HasContractRequestLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.Single( method!.CustomAttributes, candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); var bytes = Assert.Single(attribute.ConstructorArguments); Assert.Equal(110_000_000L, bytes.Value); } [Fact] public void AddMedia_HasContractMultipartBodyLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.IsType(Assert.Single( method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true))); Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit); } [Fact] public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity() { var file = new Mock(); file.SetupGet(candidate => candidate.Length).Returns(100_000_001); file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4"); file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4"); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); Assert.Equal("Videos must be 100 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } [Fact] public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity() { var file = new Mock(); file.SetupGet(candidate => candidate.Length).Returns(10_000_001); file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg"); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); Assert.Equal("Photos must be 10 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } [Fact] public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto() { // A .jpg with a foreign video type resolves to image/jpeg for validation, so it must // also be sized as a photo, not given the 100 MB video allowance. var file = new Mock(); file.SetupGet(candidate => candidate.Length).Returns(60_000_000); file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp"); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); Assert.Equal("Photos must be 10 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } [Fact] public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() { var file = new Mock(); file.SetupGet(candidate => candidate.Length).Returns(50_000_001); file.SetupGet(candidate => candidate.FileName).Returns("report.pdf"); file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf"); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); Assert.Equal("Documents must be 50 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } private static FormFile VideoFormFile(int size, string fileName, string contentType) { var bytes = new byte[size]; // ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV. bytes[4] = (byte)'f'; bytes[5] = (byte)'t'; bytes[6] = (byte)'y'; bytes[7] = (byte)'p'; bytes[8] = (byte)'i'; bytes[9] = (byte)'s'; bytes[10] = (byte)'o'; bytes[11] = (byte)'m'; return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) { Headers = new HeaderDictionary(), ContentType = contentType }; } private static (Mock Service, Mock Storage) SetupSuccessfulAddMedia() { var service = new Mock(MockBehavior.Strict); service .Setup(candidate => candidate.EnsureCanMutateMediaAsync( 1, It.IsAny(), It.IsAny(), It.IsAny(), null)) .Returns(Task.CompletedTask); service .Setup(candidate => candidate.AddMediaAsync( 1, null, "https://storage.test/stored", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" }); var storage = new Mock(MockBehavior.Strict); storage .Setup(candidate => candidate.SaveFileAsync(It.IsAny())) .ReturnsAsync("https://storage.test/stored"); return (service, storage); } [Fact] public async Task AddMedia_SixtyMegabyteMp4_IsAccepted() { var (service, storage) = SetupSuccessfulAddMedia(); var controller = CreateController(service, storage); var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4"); var result = await controller.AddMedia(1, null, file, CancellationToken.None); var ok = Assert.IsType(result); Assert.Equal(5, Assert.IsType(ok.Value).Id); } [Fact] public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() { var (service, storage) = SetupSuccessfulAddMedia(); var controller = CreateController(service, storage); var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime"); var result = await controller.AddMedia(1, null, file, CancellationToken.None); Assert.IsType(result); } [Fact] public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted() { var (service, storage) = SetupSuccessfulAddMedia(); var controller = CreateController(service, storage); var file = VideoFormFile(60_000_000, "site-clip.MOV", ""); var result = await controller.AddMedia(1, null, file, CancellationToken.None); Assert.IsType(result); } [Fact] public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted() { var (service, storage) = SetupSuccessfulAddMedia(); var controller = CreateController(service, storage); var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream"); var result = await controller.AddMedia(1, null, file, CancellationToken.None); Assert.IsType(result); } [Fact] public async Task AddMedia_HeicPhoto_IsAccepted() { var (service, storage) = SetupSuccessfulAddMedia(); var controller = CreateController(service, storage); var bytes = new byte[512]; bytes[4] = (byte)'f'; bytes[5] = (byte)'t'; bytes[6] = (byte)'y'; bytes[7] = (byte)'p'; bytes[8] = (byte)'h'; bytes[9] = (byte)'e'; bytes[10] = (byte)'i'; bytes[11] = (byte)'c'; var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic") { Headers = new HeaderDictionary(), ContentType = "image/heic" }; var result = await controller.AddMedia(1, null, file, CancellationToken.None); Assert.IsType(result); } [Fact] public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity() { var bytes = new byte[] { 1, 2, 3 }; var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "payload.exe") { Headers = new HeaderDictionary(), ContentType = "application/octet-stream" }; var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("UnsupportedMediaType", error.Code); storage.VerifyNoOtherCalls(); } [Fact] public async Task GetMediaContent_DeletedMedia_ReturnsNotFound() { var service = new Mock(MockBehavior.Strict); service.Setup(candidate => candidate.GetMediaContentAsync( 1, 10, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new WorkOrderBoardValidationException("NotFound", "Media not found.")); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(service, storage); var result = await controller.GetMediaContent(1, 10, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("NotFound", error.Code); storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); storage.VerifyNoOtherCalls(); } [Fact] public async Task DeleteMedia_Success_DoesNotCallTryDelete() { var service = new Mock(MockBehavior.Strict); service.Setup(candidate => candidate.DeleteMediaAsync( 1, 10, null, It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(service, storage); var result = await controller.DeleteMedia(1, 10); Assert.IsType(result); storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); storage.VerifyNoOtherCalls(); } } public class WorkOrderCompletionControllerUploadLimitTests { [Fact] public void UploadCompletionDoc_HasFiftyMegabyteRequestLimit() { var method = typeof(WorkOrderCompletionController).GetMethod( nameof(WorkOrderCompletionController.UploadCompletionDoc)); var attribute = Assert.Single( method!.CustomAttributes, candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); var bytes = Assert.Single(attribute.ConstructorArguments); Assert.Equal(50_000_000L, bytes.Value); } } public class WorkOrderMediaServiceCancellationTests { [Fact] public async Task GetMediaContent_ForwardsCancellationTokenToAllDataReads() { using var source = new CancellationTokenSource(); var token = source.Token; var mediaData = new Mock(MockBehavior.Strict); mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token)) .ReturnsAsync(new WorkOrder { Id = 1 }); mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, token)) .ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.test/Assets/Documents/report.pdf" }); var storage = new Mock(MockBehavior.Strict); storage.Setup(candidate => candidate.OpenRead( "https://example.test/Assets/Documents/report.pdf")) .Returns(new MemoryStream([1, 2, 3])); var service = new WorkOrderMediaService( mediaData.Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, storage.Object); var user = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, "actor-1"), new Claim(ClaimTypes.Role, "Admin"), new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) }, "Test")); var result = await service.GetMediaContentAsync(1, 10, user, "actor-1", token); result.Content.Dispose(); mediaData.Verify(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token), Times.Once); mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once); mediaData.VerifyNoOtherCalls(); } }