shoc-backend/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs
Cursor Agent b11b66538a
fix(uplifts): resolve evidence without a primary dispatch
Upload and status use the same dispatch create already selects, so a work
order with no primary can still attach evidence. Status no longer rejects a
terminal work order or dispatch, and create, cancel, and revoke return the
stored evidence file name and attachments.

Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
2026-10-01 14:55:07 +00:00

367 lines
14 KiB
C#

using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
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 UpliftDataService(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,
});
context.Dispatches.Add(new Dispatch
{
Id = 11,
VendorId = 1,
WorkOrderId = workOrder.Id,
DispatchNumber = "DIS-11",
Status = "Scheduled",
});
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
Id = 91,
VendorId = 1,
DispatchId = 11,
WorkOrderId = workOrder.Id,
OriginalFileName = "other.pdf",
StoredFileName = "other.pdf",
ContentType = "application/pdf",
Purpose = VendorDocumentPurpose.UpliftEvidence,
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));
Assert.Null(await service.GetStatusAsync(workOrder.Id, 91, Dispatcher(), CancellationToken.None));
}
[Theory]
[InlineData(LifecycleStatus.Completed, "Scheduled")]
[InlineData(LifecycleStatus.Canceled, "Scheduled")]
[InlineData(LifecycleStatus.Scheduled, "Verified")]
[InlineData(LifecycleStatus.Scheduled, "Cancelled")]
[InlineData(LifecycleStatus.Scheduled, "Canceled")]
[InlineData(LifecycleStatus.Scheduled, "Refused")]
public async Task GetStatusAsync_ReturnsUpliftEvidenceWhenTargetIsTerminal(
LifecycleStatus lifecycle,
string dispatchStatus)
{
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);
workOrder.LifecycleStatus = lifecycle;
context.Dispatches.Single().Status = dispatchStatus;
await context.SaveChangesAsync();
var status = await service.GetStatusAsync(workOrder.Id, uploaded!.Id, Dispatcher(), CancellationToken.None);
Assert.Equal("Pending", status!.ScanStatus);
Assert.Equal("quote.pdf", status.OriginalFileName);
}
[Theory]
[InlineData(LifecycleStatus.Completed, "Scheduled", "Completed")]
[InlineData(LifecycleStatus.Scheduled, "Verified", "Verified")]
public async Task UploadAsync_TerminalWorkOrderOrDispatch_Rejects(
LifecycleStatus lifecycle,
string dispatchStatus,
string expected)
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
workOrder.LifecycleStatus = lifecycle;
context.Dispatches.Single().Status = dispatchStatus;
await context.SaveChangesAsync();
var (service, storage) = NewService(context);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.UploadAsync(
workOrder.Id,
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None));
Assert.Contains(expected, ex.Message, StringComparison.Ordinal);
Assert.Empty(context.VendorCompletionDocuments);
Assert.Empty(storage.Saved);
}
[Fact]
public async Task UploadThenCreate_WithoutPrimaryDispatch_PersistsUpliftOnTheSameDispatch()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
workOrder.PrimaryDispatchId = null;
var role = new IdentityRole("Admin");
context.Roles.Add(role);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = role.Id,
});
await context.SaveChangesAsync();
var (evidence, _) = NewService(context);
var uploaded = await evidence.UploadAsync(
workOrder.Id,
FormFile("%PDF-1.4"u8.ToArray(), "quote.pdf", "application/pdf"),
Dispatcher(),
CancellationToken.None);
var pending = await evidence.GetStatusAsync(
workOrder.Id,
uploaded!.Id,
Dispatcher(),
CancellationToken.None);
Assert.Equal("Pending", pending!.ScanStatus);
var document = Assert.Single(context.VendorCompletionDocuments);
document.ScanStatus = "Passed";
await context.SaveChangesAsync();
var created = await NewUpliftService(context).CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto
{
Amount = 400m,
Notes = "Quote",
EvidenceDocumentId = uploaded.Id,
},
Dispatcher(),
CancellationToken.None);
var request = Assert.Single(context.DispatchUpliftRequests);
Assert.NotNull(created);
Assert.Equal(uploaded.Id, request.EvidenceDocumentId);
Assert.Equal(document.DispatchId, request.DispatchId);
Assert.Equal(10, request.DispatchId);
}
private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context)
{
return new WorkOrderUpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions { UpliftTier1MaxUsd = 2500m }),
new VendorDocumentDataService(context));
}
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)
{
}
}
}