mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 23:43:18 +00:00
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>
367 lines
14 KiB
C#
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)
|
|
{
|
|
}
|
|
}
|
|
}
|