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 = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader); 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("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 = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader); 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("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 = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader); 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("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 = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, 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(); } [Fact] public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo() { var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]); 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 void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage() { Assert.Equal( "Files must be 100 MB or smaller.", WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown)); } private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m']; private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0]; private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D]; /// /// A file that reports bytes but only backs the 512-byte header the /// type rules read, so oversize cases run through real signature validation cheaply. /// private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header) { var bytes = new byte[512]; header.CopyTo(bytes, 0); return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName) { Headers = new HeaderDictionary(), ContentType = contentType }; } 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_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity() { var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime"); var result = await controller.AddMedia(1, null, file, CancellationToken.None); var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("VideoTooLong", error.Code); Assert.Equal("Videos must be 90 seconds or shorter.", error.Message); storage.VerifyNoOtherCalls(); } [Fact] public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted() { var (service, storage) = SetupSuccessfulAddMedia(); var controller = CreateController(service, storage); var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", ""); var result = await controller.AddMedia(1, null, file, CancellationToken.None); Assert.IsType(result); } /// ftyp + mdat + moov/mvhd (moov last, as phones write it). private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType) { static byte[] Box(string type, byte[] body) { var box = new byte[8 + body.Length]; System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); body.CopyTo(box, 8); return box; } var mvhd = new byte[20]; System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000); System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000); var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) .Concat(Box("mdat", new byte[4096])) .Concat(Box("moov", Box("mvhd", mvhd))) .ToArray(); return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) { Headers = new HeaderDictionary(), ContentType = contentType }; } [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, new Mock(MockBehavior.Strict).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(); } [Fact] public async Task GetMediaContent_ServesHeicAsImageHeic() { const string url = "https://example.test/Assets/Images/photo.heic"; var mediaData = new Mock(MockBehavior.Strict); mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny())) .ReturnsAsync(new WorkOrder { Id = 1 }); mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny())) .ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url }); var storage = new Mock(MockBehavior.Strict); storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3])); var service = new WorkOrderMediaService( mediaData.Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, storage.Object, new Mock(MockBehavior.Strict).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"); result.Content.Dispose(); Assert.Equal("image/heic", result.ContentType); Assert.Equal("photo.heic", result.FileName); } }