shoc-backend/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs
Cursor Agent 93dda60425
fix(uplifts): serialize evidence photo count with the work-order lock
Overlapping evidence uploads could both read nine photos and both save.
The count and the insert now run under ExecuteWorkOrderMutationAsync, the
same gate the dispatcher media path uses.

Co-authored-by: Arthur Bassi <bassi-arthurr@users.noreply.github.com>
2026-10-05 15:01:59 +00:00

534 lines
20 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,
string role = "Admin",
int workOrderId = 1)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Users.Add(new ApplicationUser
{
Id = "dispatcher-1",
UserName = "dispatcher-1",
FirstName = "Alex",
LastName = "Dispatcher",
});
var identityRole = new IdentityRole(role);
context.Roles.Add(identityRole);
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = identityRole.Id,
});
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = workOrderId,
NTEAmount = 1000m,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
var workOrder = new WorkOrder
{
Id = workOrderId,
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),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
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 UploadAsync_EleventhPhoto_RejectsAndPersistsNothing()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context);
for (var i = 0; i < 10; i++)
{
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = 1,
DispatchId = 10,
WorkOrderId = workOrder.Id,
OriginalFileName = $"photo-{i}.jpg",
StoredFileName = $"photo-{i}.jpg",
ContentType = "image/jpeg",
Purpose = "Completion",
ScanStatus = "Passed",
Version = 1,
});
}
await context.SaveChangesAsync();
var (service, storage) = NewService(context);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.UploadAsync(
workOrder.Id,
FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"),
Dispatcher(),
CancellationToken.None));
Assert.Equal("A work order can have at most 10 photos.", ex.Message);
Assert.Equal(10, context.VendorCompletionDocuments.Count());
Assert.DoesNotContain(context.VendorCompletionDocuments, document => document.Purpose == VendorDocumentPurpose.UpliftEvidence);
Assert.Empty(context.WorkOrderAuditLogs);
Assert.Empty(storage.Saved);
}
[Fact]
public async Task UploadAsync_ConcurrentPhotos_PersistOnlyTheLastOpenSlot()
{
// Distinct id: the mutation gate is process-wide per work order. Nine photos
// leave one slot. Both uploads must wait on that gate; otherwise each reads 9
// and both persist.
const int workOrderId = 388_171;
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using (var seed = new ApplicationDbContext(options))
{
await SeedWorkOrderAsync(seed, workOrderId: workOrderId);
for (var i = 0; i < 9; i++)
{
seed.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = 1,
DispatchId = 10,
WorkOrderId = workOrderId,
OriginalFileName = $"photo-{i}.jpg",
StoredFileName = $"photo-{i}.jpg",
ContentType = "image/jpeg",
Purpose = "Completion",
ScanStatus = "Passed",
Version = 1,
});
}
await seed.SaveChangesAsync();
}
var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var holderContext = new ApplicationDbContext(options);
var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync(
workOrderId,
async _ =>
{
held.SetResult();
await release.Task;
return 0;
},
CancellationToken.None);
await held.Task;
await using var firstContext = new ApplicationDbContext(options);
await using var secondContext = new ApplicationDbContext(options);
var (first, firstStorage) = NewService(firstContext);
var (second, secondStorage) = NewService(secondContext);
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 };
var firstUpload = first.UploadAsync(
workOrderId,
FormFile(jpeg, "a.jpg", "image/jpeg"),
Dispatcher(),
CancellationToken.None);
var secondUpload = second.UploadAsync(
workOrderId,
FormFile(jpeg, "b.jpg", "image/jpeg"),
Dispatcher(),
CancellationToken.None);
try
{
await Task.Delay(200);
Assert.False(firstUpload.IsCompleted);
Assert.False(secondUpload.IsCompleted);
await using var during = new ApplicationDbContext(options);
Assert.Equal(9, await during.VendorCompletionDocuments.CountAsync());
}
finally
{
release.TrySetResult();
}
await holder;
var outcomes = await Task.WhenAll(Observe(firstUpload), Observe(secondUpload));
await using var verify = new ApplicationDbContext(options);
Assert.Equal(10, await verify.VendorCompletionDocuments.CountAsync());
Assert.Equal(
1,
await verify.VendorCompletionDocuments.CountAsync(
document => document.Purpose == VendorDocumentPurpose.UpliftEvidence));
Assert.Equal(1, outcomes.Count(outcome => outcome is null));
var rejected = Assert.Single(outcomes.OfType<InvalidOperationException>());
Assert.Equal("A work order can have at most 10 photos.", rejected.Message);
Assert.Equal(1, firstStorage.Saved.Count + secondStorage.Saved.Count);
static async Task<Exception?> Observe(Task<UploadCompletionDocumentResultDTO?> upload)
{
try
{
await upload;
return null;
}
catch (Exception ex)
{
return ex;
}
}
}
[Fact]
public async Task UploadAsync_RequestUpliftsDenied_PersistsNothing()
{
await using var context = CreateContext();
var workOrder = await SeedWorkOrderAsync(context, role: "Scheduler");
var (service, storage) = NewService(context);
var ex = await Assert.ThrowsAsync<UpliftForbiddenException>(() =>
service.UploadAsync(
workOrder.Id,
FormFile(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, "quote.jpg", "image/jpeg"),
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, "Scheduler"),
CancellationToken.None));
Assert.Equal(UpliftForbiddenException.RequestUpliftsDeniedMessage, ex.Message);
Assert.Empty(context.VendorCompletionDocuments);
Assert.Empty(context.WorkOrderAuditLogs);
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;
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)
{
}
}
}