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()); 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 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); } } }