mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
A misleading file name could move a file into a larger size class: a real PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal, and a .jpg declared with a foreign video type got it on the media endpoint. Classify by the same resolved type the signature check validates; the extension only decides when no allowlisted type is known.
672 lines
27 KiB
C#
672 lines
27 KiB
C#
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<ApplicationDbContext>()
|
|
.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<IWebHostEnvironment>();
|
|
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
|
|
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
|
|
|
|
var upliftData = new Mock<IUpliftDataService>();
|
|
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<PortalUpliftData>());
|
|
var commentData = new Mock<ICommentDataService>();
|
|
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<PortalCommentData>());
|
|
|
|
return new VendorPortalService(
|
|
tokenService,
|
|
new DispatchDataService(context),
|
|
upliftData.Object,
|
|
commentData.Object,
|
|
Mock.Of<IUserDataService>(),
|
|
Mock.Of<IEmailSender>(),
|
|
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<ILogger<VendorPortalController>>());
|
|
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<HttpPostAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
var downloadRoutes = typeof(VendorPortalController)
|
|
.GetMethod(nameof(VendorPortalController.DownloadCompletionDocument))!
|
|
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
|
|
.Cast<HttpGetAttribute>()
|
|
.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<BadRequestObjectResult>();
|
|
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<OkObjectResult>();
|
|
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<ObjectResult>().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<OkObjectResult>();
|
|
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<OkObjectResult>();
|
|
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<OkObjectResult>();
|
|
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<BadRequestObjectResult>();
|
|
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<BadRequestObjectResult>();
|
|
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<OkObjectResult>();
|
|
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<OkObjectResult>();
|
|
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<OkObjectResult>();
|
|
(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<OkObjectResult>();
|
|
(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 })]
|
|
[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<BadRequestObjectResult>();
|
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
|
}
|
|
|
|
[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<BadRequestObjectResult>();
|
|
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<BadRequestObjectResult>();
|
|
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<BadRequestObjectResult>();
|
|
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<OkObjectResult>().Subject;
|
|
var ownerResponse = ownerOk.Value.Should().BeOfType<DataResponse>().Subject;
|
|
var ownerData = ((object)ownerResponse.Data)
|
|
.Should().BeOfType<SeaHaven.Services.DTOs.VendorDispatchDetailDTO>().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<NotFoundObjectResult>();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_contentRoot))
|
|
{
|
|
Directory.Delete(_contentRoot, recursive: true);
|
|
}
|
|
}
|
|
}
|