shoc-backend/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs
Arthur Bassi 31d4352a23 fix(work-orders): accept a scanned uplift evidence file (SH-388)
Dispatchers can attach one evidence file, and create links it only
after that document has passed scanning.
2026-09-24 14:34:07 -03:00

219 lines
8 KiB
C#

using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace SeaHavenIndustries.Tests;
public sealed class WorkOrderUpliftEvidenceServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task<WorkOrder> SeedWorkOrderAsync(ApplicationDbContext context)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Users.Add(new ApplicationUser
{
Id = "dispatcher-1",
UserName = "dispatcher-1",
FirstName = "Alex",
LastName = "Dispatcher",
});
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
NTEAmount = 1000m,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
var workOrder = new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
PrimaryDispatchId = 10,
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
};
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
return workOrder;
}
private static (WorkOrderUpliftEvidenceService Service, MemoryStorage Storage) NewService(
ApplicationDbContext context,
bool passWhenScannerUnavailable = false)
{
var storage = new MemoryStorage();
var service = new WorkOrderUpliftEvidenceService(
new WorkOrderDetailDataService(context),
new DispatchDataService(context),
new VendorDocumentDataService(context),
storage,
WorkOrderAccountTestHelpers.Resolver(context),
Options.Create(new VendorDocumentsOptions
{
PassWhenScannerUnavailable = passWhenScannerUnavailable,
}));
return (service, storage);
}
private static ClaimsPrincipal Dispatcher()
=> WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1");
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 async Task UploadAsync_StoresPendingUpliftEvidenceForThePrimaryDispatch()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
var (service, storage) = NewService(context);
var pdf = "%PDF-1.4"u8.ToArray();
var uploaded = await service.UploadAsync(
workOrder.Id,
FormFile(pdf, "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None);
var document = Assert.Single(context.VendorCompletionDocuments);
Assert.Equal(document.Id, uploaded!.Id);
Assert.Equal("Pending", uploaded.ScanStatus);
Assert.Null(document.ScannedAt);
Assert.Equal(VendorDocumentPurpose.UpliftEvidence, document.Purpose);
Assert.Equal(1, document.VendorId);
Assert.Equal(10, document.DispatchId);
Assert.Equal("quote.pdf", document.OriginalFileName);
Assert.Null(document.ReplacesDocumentId);
var saved = Assert.Single(storage.Saved);
Assert.Equal((1, 10), (saved.VendorId, saved.DispatchId));
var audit = Assert.Single(context.WorkOrderAuditLogs);
Assert.Equal("uplift_evidence_uploaded", audit.Action);
Assert.Equal("internal", audit.ActorType);
Assert.Equal("dispatcher-1", audit.UserId);
}
[Fact]
public async Task UploadAsync_PassWhenScannerUnavailable_StoresPassedEvidence()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
var (service, _) = NewService(context, passWhenScannerUnavailable: true);
var uploaded = await service.UploadAsync(
workOrder.Id,
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None);
var document = Assert.Single(context.VendorCompletionDocuments);
Assert.Equal("Passed", uploaded!.ScanStatus);
Assert.Equal("Passed", document.ScanStatus);
Assert.NotNull(document.ScannedAt);
Assert.Equal("Processing", document.ReviewStatus);
}
[Fact]
public async Task UploadAsync_MismatchedSignature_SavesNothing()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
var (service, storage) = NewService(context);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.UploadAsync(
workOrder.Id,
FormFile("not a pdf"u8.ToArray(), "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None));
Assert.Contains("signature", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Empty(context.VendorCompletionDocuments);
Assert.Empty(storage.Saved);
}
[Fact]
public async Task GetStatusAsync_ReturnsScanStatusOnlyForUpliftEvidenceOnTheDispatch()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
var (service, _) = NewService(context);
var uploaded = await service.UploadAsync(
workOrder.Id,
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None);
var pending = await service.GetStatusAsync(workOrder.Id, uploaded!.Id, Dispatcher(), CancellationToken.None);
Assert.Equal("Pending", pending!.ScanStatus);
Assert.Equal("quote.pdf", pending.OriginalFileName);
var stored = Assert.Single(context.VendorCompletionDocuments);
stored.ScanStatus = "Passed";
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
Id = 90,
VendorId = 1,
DispatchId = 10,
WorkOrderId = workOrder.Id,
OriginalFileName = "done.pdf",
StoredFileName = "done.pdf",
ContentType = "application/pdf",
Purpose = "Completion",
ScanStatus = "Passed",
Version = 1,
});
await context.SaveChangesAsync();
var passed = await service.GetStatusAsync(workOrder.Id, uploaded.Id, Dispatcher(), CancellationToken.None);
Assert.Equal("Passed", passed!.ScanStatus);
Assert.Null(await service.GetStatusAsync(workOrder.Id, 90, Dispatcher(), CancellationToken.None));
}
private sealed class MemoryStorage : IVendorDocumentStoragePort
{
public List<(int VendorId, int DispatchId, string Name)> Saved { get; } = new();
public async Task SaveAsync(
int vendorId,
int dispatchId,
string storedFileName,
Stream content,
CancellationToken cancellationToken)
{
using var buffer = new MemoryStream();
await content.CopyToAsync(buffer, cancellationToken);
Saved.Add((vendorId, dispatchId, storedFileName));
}
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
=> throw new NotSupportedException();
public void Delete(int vendorId, int dispatchId, string storedFileName)
{
}
}
}