mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
The 10-photo / 3-video cap was a check-then-insert with no lock on both upload surfaces, so two overlapping uploads could both take the last slot. The board media upload and the vendor portal upload now run count, insert and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic to image/heic.
449 lines
19 KiB
C#
449 lines
19 KiB
C#
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<IWorkOrderMediaService>? service = null,
|
|
Mock<IFileStoragePort>? storage = null)
|
|
{
|
|
var controller = new WorkOrderMediaController(
|
|
(service ?? new Mock<IWorkOrderMediaService>()).Object,
|
|
(storage ?? new Mock<IFileStoragePort>()).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<RequestFormLimitsAttribute>(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<IFormFile>();
|
|
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<IFileStoragePort>(MockBehavior.Strict);
|
|
var controller = CreateController(storage: storage);
|
|
|
|
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
|
|
|
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFormFile>();
|
|
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<IFileStoragePort>(MockBehavior.Strict);
|
|
var controller = CreateController(storage: storage);
|
|
|
|
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
|
|
|
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFormFile>();
|
|
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<IFileStoragePort>(MockBehavior.Strict);
|
|
var controller = CreateController(storage: storage);
|
|
|
|
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
|
|
|
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFormFile>();
|
|
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<IFileStoragePort>(MockBehavior.Strict);
|
|
var controller = CreateController(storage: storage);
|
|
|
|
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, CancellationToken.None);
|
|
|
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IWorkOrderMediaService> Service, Mock<IFileStoragePort> Storage) SetupSuccessfulAddMedia()
|
|
{
|
|
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
|
|
service
|
|
.Setup(candidate => candidate.EnsureCanMutateMediaAsync(
|
|
1, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), null))
|
|
.Returns(Task.CompletedTask);
|
|
service
|
|
.Setup(candidate => candidate.AddMediaAsync(
|
|
1, null, "https://storage.test/stored", It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" });
|
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
|
storage
|
|
.Setup(candidate => candidate.SaveFileAsync(It.IsAny<IFormFile>()))
|
|
.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<OkObjectResult>(result);
|
|
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
|
|
{
|
|
var storage = new Mock<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
|
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<OkObjectResult>(result);
|
|
}
|
|
|
|
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
|
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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<IFileStoragePort>(MockBehavior.Strict);
|
|
var controller = CreateController(storage: storage);
|
|
|
|
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
|
|
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
|
Assert.Equal("UnsupportedMediaType", error.Code);
|
|
storage.VerifyNoOtherCalls();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMediaContent_DeletedMedia_ReturnsNotFound()
|
|
{
|
|
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
|
|
service.Setup(candidate => candidate.GetMediaContentAsync(
|
|
1, 10, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new WorkOrderBoardValidationException("NotFound", "Media not found."));
|
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
|
var controller = CreateController(service, storage);
|
|
|
|
var result = await controller.GetMediaContent(1, 10, CancellationToken.None);
|
|
|
|
var response = Assert.IsType<NotFoundObjectResult>(result);
|
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
|
Assert.Equal("NotFound", error.Code);
|
|
storage.Verify(candidate => candidate.TryDelete(It.IsAny<string>()), Times.Never);
|
|
storage.VerifyNoOtherCalls();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteMedia_Success_DoesNotCallTryDelete()
|
|
{
|
|
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
|
|
service.Setup(candidate => candidate.DeleteMediaAsync(
|
|
1, 10, null, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
|
var controller = CreateController(service, storage);
|
|
|
|
var result = await controller.DeleteMedia(1, 10);
|
|
|
|
Assert.IsType<NoContentResult>(result);
|
|
storage.Verify(candidate => candidate.TryDelete(It.IsAny<string>()), 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<IWorkOrderMediaDataService>(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<IFileStoragePort>(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<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
|
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
|
storage.Object,
|
|
new Mock<IUpliftDataService>(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<IWorkOrderMediaDataService>(MockBehavior.Strict);
|
|
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new WorkOrder { Id = 1 });
|
|
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
|
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
|
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
|
|
var service = new WorkOrderMediaService(
|
|
mediaData.Object,
|
|
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
|
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
|
storage.Object,
|
|
new Mock<IUpliftDataService>(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);
|
|
}
|
|
}
|