shoc-backend/SeaHavenIndustries.Tests/WorkOrderUpliftEvidenceServiceTests.cs

535 lines
20 KiB
C#
Raw Normal View History

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)
{
}
}
}