using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.Infrastructure; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; public sealed class VendorPortalDocumentTests : IDisposable { private const string Token = "vendor-document-test-token"; private readonly string _contentRoot = Path.Combine( Path.GetTempPath(), $"seahaven-vendor-documents-{Guid.NewGuid():N}"); private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private VendorPortalController NewController(ApplicationDbContext context) { return NewController(NewService(context)); } private VendorPortalService NewService(ApplicationDbContext context) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService( vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); var documentData = new VendorDocumentDataService(context); var storageEnvironment = new Mock(); storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object); var upliftData = new Mock(); upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync( It.IsAny(), It.IsAny>>(), It.IsAny())) .Returns((int _, Func> work, CancellationToken ct) => work(ct)); var commentData = new Mock(); commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); return new VendorPortalService( tokenService, new DispatchDataService(context), upliftData.Object, commentData.Object, Mock.Of(), Mock.Of(), documentData, storage, Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()), TimeProvider.System); } private static VendorPortalController NewController(VendorPortalService service) { var controller = new VendorPortalController(service, Mock.Of>()); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; controller.Request.Headers["X-Vendor-Token"] = Token; return controller; } private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch( ApplicationDbContext context) { var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var dispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Completed" }; context.Dispatches.Add(dispatch); context.VendorAccessTokens.Add(new VendorAccessToken { VendorId = vendor.Id, Token = Token, IssuedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(1) }); await context.SaveChangesAsync(); return (vendor, dispatch); } private static FormFile FormFile(byte[] bytes, string fileName, string contentType) { return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) { Headers = new HeaderDictionary(), ContentType = contentType }; } [Fact] public void CompletionDocumentEndpoints_AdvertiseCanonicalAndCompatibilityRoutes() { var uploadRoutes = typeof(VendorPortalController) .GetMethod(nameof(VendorPortalController.UploadCompletionDocument))! .GetCustomAttributes(typeof(HttpPostAttribute), inherit: false) .Cast() .Select(attribute => attribute.Template); var downloadRoutes = typeof(VendorPortalController) .GetMethod(nameof(VendorPortalController.DownloadCompletionDocument))! .GetCustomAttributes(typeof(HttpGetAttribute), inherit: false) .Cast() .Select(attribute => attribute.Template); uploadRoutes.Should().BeEquivalentTo( "dispatches/{id:int}/completion-documents", "dispatches/{id:int}/documents"); downloadRoutes.Should().BeEquivalentTo( "dispatches/{id:int}/completion-documents/{documentId:int}", "dispatches/{id:int}/documents/{documentId:int}"); } [Fact] public async Task UploadCompletionDocument_RejectsMismatchedFileSignature() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().BeEmpty(); } [Fact] public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var controller = NewController(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); var upload = await controller.UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion.pdf", "application/pdf")); upload.Should().BeOfType(); var document = await context.VendorCompletionDocuments.SingleAsync(); document.ScanStatus.Should().Be("Pending"); document.ReviewStatus.Should().Be("Processing"); document.ReplacesDocumentId.Should().BeNull(); File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue(); var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id); var locked = download.Should().BeOfType().Subject; locked.StatusCode.Should().Be(StatusCodes.Status423Locked); } [Fact] public async Task GetDocumentStatus_ReturnsOwnedUpliftEvidenceMetadata() { using var context = NewContext(); var (vendor, dispatch) = await SeedDispatch(context); var document = new VendorCompletionDocument { VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = dispatch.WorkOrderId!.Value, OriginalFileName = "estimate.pdf", StoredFileName = "stored.pdf", ContentType = "application/pdf", Purpose = VendorDocumentPurpose.UpliftEvidence, ScanStatus = "Passed", ReviewStatus = "Accepted" }; context.VendorCompletionDocuments.Add(document); await context.SaveChangesAsync(); var result = await NewService(context).GetDocumentStatusAsync( new VendorPortalSession { Id = vendor.Id }, dispatch.Id, document.Id, CancellationToken.None); result.Should().NotBeNull(); result!.Id.Should().Be(document.Id); result.OriginalFileName.Should().Be("estimate.pdf"); result.Purpose.Should().Be(VendorDocumentPurpose.UpliftEvidence); result.ScanStatus.Should().Be("Passed"); } [Fact] public async Task GetDocumentStatus_DoesNotExposeAnotherVendorsDocument() { using var context = NewContext(); var (vendor, dispatch) = await SeedDispatch(context); var document = new VendorCompletionDocument { VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = dispatch.WorkOrderId!.Value, OriginalFileName = "estimate.pdf", StoredFileName = "stored.pdf", ContentType = "application/pdf", Purpose = VendorDocumentPurpose.UpliftEvidence }; context.VendorCompletionDocuments.Add(document); await context.SaveChangesAsync(); var result = await NewService(context).GetDocumentStatusAsync( new VendorPortalSession { Id = vendor.Id + 1 }, dispatch.Id, document.Id, CancellationToken.None); result.Should().BeNull(); } [Fact] public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion.pdf", "Application/PDF")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().HaveCount(1); } [Fact] public async Task UploadCompletionDocument_UsesExplicitOwnedReplacement() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var controller = NewController(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); var firstUpload = await controller.UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion-v1.pdf", "application/pdf")); firstUpload.Should().BeOfType(); var first = await context.VendorCompletionDocuments.SingleAsync(); var replacementUpload = await controller.UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion-v2.pdf", "application/pdf"), first.Id); replacementUpload.Should().BeOfType(); var replacement = await context.VendorCompletionDocuments .OrderBy(document => document.Version) .LastAsync(); replacement.Version.Should().Be(2); replacement.ReplacesDocumentId.Should().Be(first.Id); } [Fact] public async Task UploadCompletionDocument_RejectsReplacementFromAnotherDispatch() { using var context = NewContext(); var (vendor, dispatch) = await SeedDispatch(context); var controller = NewController(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); await controller.UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion-v1.pdf", "application/pdf")); var existing = await context.VendorCompletionDocuments.SingleAsync(); var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other repair" }; context.Add(otherWorkOrder); await context.SaveChangesAsync(); var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" }; context.Dispatches.Add(otherDispatch); await context.SaveChangesAsync(); var result = await controller.UploadCompletionDocument( otherDispatch.Id, FormFile(pdf, "replacement.pdf", "application/pdf"), existing.Id); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().ContainSingle(); } [Fact] public async Task UploadCompletionDocument_RejectsReplacementOwnedByAnotherVendor() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var otherVendor = new Vendor { CompanyName = "Other vendor", IsActive = true }; var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other vendor repair" }; context.AddRange(otherVendor, otherWorkOrder); await context.SaveChangesAsync(); var otherDispatch = new Dispatch { VendorId = otherVendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" }; context.Dispatches.Add(otherDispatch); await context.SaveChangesAsync(); var otherDocument = new VendorCompletionDocument { VendorId = otherVendor.Id, DispatchId = otherDispatch.Id, WorkOrderId = otherWorkOrder.Id, OriginalFileName = "other.pdf", StoredFileName = "other.pdf", ContentType = "application/pdf", SizeBytes = 4, Version = 1 }; context.VendorCompletionDocuments.Add(otherDocument); await context.SaveChangesAsync(); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile("%PDF-1.4\nreplacement"u8.ToArray(), "replacement.pdf", "application/pdf"), otherDocument.Id); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().ContainSingle(); } [Fact] public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 }; var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(png, "photo.png", "Image/PNG")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().HaveCount(1); } [Fact] public void UploadCompletionDocument_AdvertisesContractRequestLimit() { var attribute = Assert.Single( typeof(VendorPortalController) .GetMethod(nameof(VendorPortalController.UploadCompletionDocument))! .CustomAttributes, candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); var bytes = Assert.Single(attribute.ConstructorArguments); bytes.Value.Should().Be(110_000_000L); } private static byte[] MediaBytes(int size, params byte[] header) { var bytes = new byte[size]; header.AsSpan().CopyTo(bytes); return bytes; } private static byte[] FtypVideoBytes(int size) => MediaBytes( size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m'); [Fact] public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4")); result.Should().BeOfType(); var document = await context.VendorCompletionDocuments.SingleAsync(); document.ContentType.Should().Be("video/mp4"); document.SizeBytes.Should().Be(60_000_000L); } [Fact] public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "")); result.Should().BeOfType(); (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime"); } [Fact] public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic")); result.Should().BeOfType(); (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); } [Theory] // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must // follow the validated type, not the name, or the document/photo caps are bypassed. [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })] [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( string fileName, string contentType, int size, byte[] header) { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(MediaBytes(size, header), fileName, contentType)); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().BeEmpty(); } /// ftyp + mdat + moov/mvhd (moov last, as phones write it). private static byte[] PhoneVideoBytes(uint durationSeconds) { 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), 600); System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600); return 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(); } [Theory] [InlineData(95u, false)] [InlineData(89u, true)] public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted) { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime")); if (accepted) { result.Should().BeOfType(); } else { result.Should().BeOfType(); context.VendorCompletionDocuments.Should().BeEmpty(); } } [Fact] public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" }) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = dispatch.WorkOrderId!.Value, Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", }); } await context.SaveChangesAsync(); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().BeEmpty(); } [Fact] public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces() { using var context = NewContext(); var (vendor, dispatch) = await SeedDispatch(context); foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = dispatch.WorkOrderId!.Value, Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", }); } context.VendorCompletionDocuments.Add(new VendorCompletionDocument { VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = dispatch.WorkOrderId!.Value, ContentType = "video/mp4", StoredFileName = "v1.mp4", Version = 1, Purpose = "Completion" }); await context.SaveChangesAsync(); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().HaveCount(2); } [Fact] public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck() { using var context = NewContext(); var (vendor, dispatch) = await SeedDispatch(context); foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" }) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = dispatch.WorkOrderId!.Value, Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", }); } context.VendorCompletionDocuments.Add(new VendorCompletionDocument { VendorId = vendor.Id, DispatchId = dispatch.Id, WorkOrderId = dispatch.WorkOrderId!.Value, ContentType = "video/mp4", StoredFileName = "v1.mp4", Version = 1, Purpose = "Completion" }); await context.SaveChangesAsync(); var service = NewService(context); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO { MaxPhotos = 10, MaxVideos = 3, Photos = 1, Videos = 3, CompletionPhotos = 1, CompletionVideos = 2, }); } [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().BeEmpty(); } [Fact] public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().BeEmpty(); } [Fact] public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var result = await NewController(context).UploadCompletionDocument( dispatch.Id, FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4")); result.Should().BeOfType(); context.VendorCompletionDocuments.Should().BeEmpty(); } [Fact] public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var service = NewService(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); await NewController(service).UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion-v1.pdf", "application/pdf")); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var firstDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); firstDetail.Should().NotBeNull(); var firstDoc = firstDetail!.Documents.Should().ContainSingle().Subject; firstDoc.OriginalFileName.Should().Be("completion-v1.pdf"); firstDoc.ContentType.Should().Be("application/pdf"); firstDoc.SizeBytes.Should().Be(pdf.Length); firstDoc.ScanStatus.Should().Be("Pending"); firstDoc.ReviewStatus.Should().Be("Processing"); firstDoc.Version.Should().Be(1); firstDoc.ReplacesDocumentId.Should().BeNull(); firstDoc.CanDownload.Should().BeFalse(); firstDoc.CreatedDate.Should().NotBeNull(); var first = await context.VendorCompletionDocuments.SingleAsync(); await NewController(service).UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion-v2.pdf", "application/pdf"), first.Id); var secondDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); var ordered = secondDetail!.Documents.OrderByDescending(d => d.Version).ToList(); ordered.Should().HaveCount(2); ordered[0].Version.Should().Be(2); ordered[0].ReplacesDocumentId.Should().Be(first.Id); ordered[0].OriginalFileName.Should().Be("completion-v2.pdf"); ordered[1].Version.Should().Be(1); ordered[1].ReplacesDocumentId.Should().BeNull(); } [Fact] public async Task GetDispatchDetail_EnablesDownloadOnlyWhenScanPasses() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var service = NewService(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); await NewController(service).UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion.pdf", "application/pdf")); var document = await context.VendorCompletionDocuments.SingleAsync(); document.ScanStatus = "Passed"; document.ScannedAt = DateTime.UtcNow; await context.SaveChangesAsync(); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); var doc = detail!.Documents.Should().ContainSingle().Subject; doc.ScanStatus.Should().Be("Passed"); doc.CanDownload.Should().BeTrue(); doc.ScannedAt.Should().NotBeNull(); } [Theory] [InlineData("Verified")] [InlineData("Cancelled")] public async Task GetDispatchDetail_DisablesDownloadForLockedDispatches(string status) { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var service = NewService(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); await NewController(service).UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion.pdf", "application/pdf")); var document = await context.VendorCompletionDocuments.SingleAsync(); document.ScanStatus = "Passed"; dispatch.Status = status; await context.SaveChangesAsync(); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); detail!.Documents.Should().ContainSingle().Which.CanDownload.Should().BeFalse(); } [Fact] public async Task GetDispatchDetail_ExcludesSoftDeletedDocuments() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); var service = NewService(context); var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); await NewController(service).UploadCompletionDocument( dispatch.Id, FormFile(pdf, "completion.pdf", "application/pdf")); var document = await context.VendorCompletionDocuments.SingleAsync(); document.IsDeleted = true; await context.SaveChangesAsync(); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); detail!.Documents.Should().BeEmpty(); } [Fact] public async Task GetDispatchDetail_ReturnsInvoicePaymentFieldsOnlyForOwningVendor() { using var context = NewContext(); var (_, dispatch) = await SeedDispatch(context); dispatch.InvoiceNumber = "INV-2048"; dispatch.InvoiceStatus = "Confirmed"; dispatch.PaymentStatus = "Scheduled"; await context.SaveChangesAsync(); var service = NewService(context); var ownerSession = await service.ResolveSessionAsync(Token, CancellationToken.None); var ownerDetail = await service.GetDispatchDetailAsync(ownerSession!, dispatch.Id, CancellationToken.None); ownerDetail.Should().NotBeNull(); ownerDetail!.InvoiceNumber.Should().Be("INV-2048"); ownerDetail.InvoiceStatus.Should().Be("Confirmed"); ownerDetail.PaymentStatus.Should().Be("Scheduled"); var ownerResult = await NewController(service).GetDispatch(dispatch.Id); var ownerOk = ownerResult.Should().BeOfType().Subject; var ownerResponse = ownerOk.Value.Should().BeOfType().Subject; var ownerData = ((object)ownerResponse.Data) .Should().BeOfType().Subject; ownerData.InvoiceNumber.Should().Be("INV-2048"); ownerData.InvoiceStatus.Should().Be("Confirmed"); ownerData.PaymentStatus.Should().Be("Scheduled"); var otherVendor = new Vendor { CompanyName = "Other Vendor", IsActive = true }; context.Vendors.Add(otherVendor); await context.SaveChangesAsync(); const string otherToken = "other-vendor-token"; context.VendorAccessTokens.Add(new VendorAccessToken { VendorId = otherVendor.Id, Token = otherToken, IssuedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(1) }); await context.SaveChangesAsync(); var otherSession = await service.ResolveSessionAsync(otherToken, CancellationToken.None); var otherDetail = await service.GetDispatchDetailAsync(otherSession!, dispatch.Id, CancellationToken.None); otherDetail.Should().BeNull(); var otherController = NewController(service); otherController.Request.Headers["X-Vendor-Token"] = otherToken; var otherResult = await otherController.GetDispatch(dispatch.Id); otherResult.Should().BeOfType(); } public void Dispose() { if (Directory.Exists(_contentRoot)) { Directory.Delete(_contentRoot, recursive: true); } } }