mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +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.
817 lines
32 KiB
C#
817 lines
32 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>());
|
|
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
|
|
It.IsAny<int>(),
|
|
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
|
|
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, 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<BadRequestObjectResult>();
|
|
context.VendorCompletionDocuments.Should().BeEmpty();
|
|
}
|
|
|
|
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
|
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<OkObjectResult>();
|
|
}
|
|
else
|
|
{
|
|
result.Should().BeOfType<BadRequestObjectResult>();
|
|
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<BadRequestObjectResult>();
|
|
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<OkObjectResult>();
|
|
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<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);
|
|
}
|
|
}
|
|
}
|