shoc-backend/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
2026-07-28 14:19:15 -03:00

433 lines
17 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.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()));
}
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 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 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();
}
public void Dispose()
{
if (Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}