mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 18:53:12 +00:00
Merge pull request #177 from Sea-Haven-Industries/fix/ab/sh-397-uplift-decision-audit
fix(uplifts): let admins decide uplifts whose dispatch has no work order
This commit is contained in:
commit
50e5553e57
4 changed files with 371 additions and 10 deletions
|
|
@ -681,6 +681,8 @@ public sealed class UpliftWorkflowTests
|
|||
var upliftData = new Mock<IUpliftDataService>();
|
||||
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
|
||||
upliftData.Setup(u => u.GetWorkOrderIdForUpliftAsync(202, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(99);
|
||||
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecidedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
NewValue = UpliftStatus.Expired,
|
||||
|
|
@ -136,9 +136,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.EscalatedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Escalated",
|
||||
|
|
@ -247,5 +247,19 @@ namespace SeaHaven.Services.Implementation
|
|||
req.LastModificationTime = now;
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// The audit goes to the work order the uplift's dispatch belongs to (owner, else the
|
||||
// one it is linked to). With neither, the status change is recorded on the request
|
||||
// alone; a work-order id of 0 would fail the required audit foreign key and abort
|
||||
// the sweep on every run.
|
||||
private async Task StageAuditAsync(
|
||||
int upliftId,
|
||||
Func<int, WorkOrderAuditLog> buildEntry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
|
||||
if (workOrderId is int resolved)
|
||||
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,9 +166,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
||||
OldValue = $"${oldNTE:F2}",
|
||||
|
|
@ -252,9 +252,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecisionNote = note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
|
|
@ -290,9 +290,9 @@ namespace SeaHaven.Services.Implementation
|
|||
req.DecisionNote = note.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
await StageDecisionAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
|
|
@ -307,6 +307,19 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier);
|
||||
|
||||
// The decision audit goes to the work order the uplift's dispatch belongs to (owner,
|
||||
// else the one it is linked to). A dispatch with neither has no work order to audit
|
||||
// against, so the decision is recorded on the request alone.
|
||||
private async Task StageDecisionAuditAsync(
|
||||
int upliftId,
|
||||
Func<int, WorkOrderAuditLog> buildEntry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
|
||||
if (workOrderId is int resolved)
|
||||
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
|
||||
}
|
||||
|
||||
// SH-101: authorized internal download of a Passed UpliftEvidence file. The data
|
||||
// service resolves the document by the request's own linkage (no client-supplied
|
||||
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
||||
|
|
|
|||
332
SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs
Normal file
332
SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
// Relational (SQLite, foreign keys on) coverage for the work-order audit entry written by
|
||||
// uplift decisions. WorkOrderAuditLogs.WorkOrderId is a required FK, so the in-memory
|
||||
// provider cannot prove a decision on a dispatch without an owning work order commits.
|
||||
// The audit goes to the work order the dispatch belongs to (owner, else the one it is
|
||||
// linked to through DispatchWorkOrders); when none resolves, the decision is still saved
|
||||
// and no audit row is written.
|
||||
public sealed class UpliftDecisionAuditRelationalTests
|
||||
{
|
||||
private const string AdminId = "admin-1";
|
||||
|
||||
public enum Ownership
|
||||
{
|
||||
Owned,
|
||||
Linked,
|
||||
Unresolvable
|
||||
}
|
||||
|
||||
public enum Decision
|
||||
{
|
||||
Approve,
|
||||
Reject,
|
||||
RequestChanges
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Decision.Approve, Ownership.Owned)]
|
||||
[InlineData(Decision.Approve, Ownership.Linked)]
|
||||
[InlineData(Decision.Approve, Ownership.Unresolvable)]
|
||||
[InlineData(Decision.Reject, Ownership.Owned)]
|
||||
[InlineData(Decision.Reject, Ownership.Linked)]
|
||||
[InlineData(Decision.Reject, Ownership.Unresolvable)]
|
||||
[InlineData(Decision.RequestChanges, Ownership.Owned)]
|
||||
[InlineData(Decision.RequestChanges, Ownership.Linked)]
|
||||
[InlineData(Decision.RequestChanges, Ownership.Unresolvable)]
|
||||
public async Task Decision_CommitsAndAuditsResolvedWorkOrder(Decision decision, Ownership ownership)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
|
||||
|
||||
int upliftId;
|
||||
int? expectedWorkOrderId;
|
||||
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: null);
|
||||
}
|
||||
|
||||
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
var service = new UpliftService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
new NoDocumentStorage(),
|
||||
TimeProvider.System,
|
||||
Options.Create(new ApprovalsOptions()));
|
||||
var admin = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, AdminId),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
}, "Test"));
|
||||
|
||||
switch (decision)
|
||||
{
|
||||
case Decision.Approve:
|
||||
await service.ApproveAsync(admin, upliftId, null, CancellationToken.None);
|
||||
break;
|
||||
case Decision.Reject:
|
||||
await service.RejectAsync(admin, upliftId, "too high", CancellationToken.None);
|
||||
break;
|
||||
case Decision.RequestChanges:
|
||||
await service.RequestChangesAsync(admin, upliftId, "send a quote", CancellationToken.None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
|
||||
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||
var expectedStatus = decision switch
|
||||
{
|
||||
Decision.Approve => UpliftStatus.Approved,
|
||||
Decision.Reject => UpliftStatus.Rejected,
|
||||
_ => UpliftStatus.ChangesRequested
|
||||
};
|
||||
Assert.Equal(expectedStatus, saved.Status);
|
||||
Assert.Equal(AdminId, saved.DecidedByUserId);
|
||||
if (decision == Decision.Approve)
|
||||
{
|
||||
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync(d => d.Id == saved.DispatchId);
|
||||
Assert.Equal(5_000m, dispatch.NTEAmount);
|
||||
}
|
||||
|
||||
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
|
||||
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
|
||||
if (expectedWorkOrderId is int workOrderId)
|
||||
{
|
||||
var audit = Assert.Single(audits);
|
||||
Assert.Equal(workOrderId, audit.WorkOrderId);
|
||||
Assert.StartsWith("uplift_", audit.Action);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Ownership.Linked)]
|
||||
[InlineData(Ownership.Unresolvable)]
|
||||
public async Task Expiry_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
|
||||
|
||||
int upliftId;
|
||||
int? expectedWorkOrderId;
|
||||
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
(upliftId, expectedWorkOrderId) = await SeedAsync(seed, ownership, expiresAt: DateTime.UtcNow.AddHours(-1));
|
||||
}
|
||||
|
||||
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
null!,
|
||||
new NoEmailSender(),
|
||||
Options.Create(new FrontendOptions()),
|
||||
Options.Create(new ApprovalsOptions()),
|
||||
TimeProvider.System,
|
||||
NullLogger<UpliftLifecycleService>.Instance);
|
||||
|
||||
Assert.Equal(1, await lifecycle.ExpireDueAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
|
||||
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||
Assert.Equal(UpliftStatus.Expired, saved.Status);
|
||||
|
||||
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
|
||||
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
|
||||
if (expectedWorkOrderId is int workOrderId)
|
||||
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
|
||||
else
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Ownership.Linked)]
|
||||
[InlineData(Ownership.Unresolvable)]
|
||||
public async Task Escalation_CommitsAndAuditsResolvedWorkOrder(Ownership ownership)
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
|
||||
|
||||
int upliftId;
|
||||
int? expectedWorkOrderId;
|
||||
await using (var seed = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
await seed.Database.EnsureCreatedAsync();
|
||||
(upliftId, expectedWorkOrderId) = await SeedAsync(
|
||||
seed,
|
||||
ownership,
|
||||
expiresAt: null,
|
||||
initialNotificationSentAt: DateTime.UtcNow.AddDays(-3));
|
||||
}
|
||||
|
||||
var emailSender = new NoEmailSender();
|
||||
await using (var context = new SqliteUpliftDecisionTestDbContext(options))
|
||||
{
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
null!,
|
||||
emailSender,
|
||||
Options.Create(new FrontendOptions()),
|
||||
Options.Create(new ApprovalsOptions { EscalationRecipients = new[] { "escalate@test.local" } }),
|
||||
TimeProvider.System,
|
||||
NullLogger<UpliftLifecycleService>.Instance);
|
||||
|
||||
Assert.Equal(1, await lifecycle.EscalateDueAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
await using var verify = new SqliteUpliftDecisionTestDbContext(options);
|
||||
var saved = await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||
Assert.NotNull(saved.EscalatedAt);
|
||||
Assert.Equal(1, emailSender.Calls);
|
||||
|
||||
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
|
||||
Assert.DoesNotContain(audits, a => a.WorkOrderId == 0);
|
||||
if (expectedWorkOrderId is int workOrderId)
|
||||
Assert.Equal(workOrderId, Assert.Single(audits).WorkOrderId);
|
||||
else
|
||||
Assert.Empty(audits);
|
||||
}
|
||||
|
||||
private static async Task<(int UpliftId, int? ExpectedWorkOrderId)> SeedAsync(
|
||||
ApplicationDbContext context,
|
||||
Ownership ownership,
|
||||
DateTime? expiresAt,
|
||||
DateTime? initialNotificationSentAt = null)
|
||||
{
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = AdminId,
|
||||
UserName = AdminId,
|
||||
NormalizedUserName = AdminId.ToUpperInvariant(),
|
||||
Email = "admin@test.local",
|
||||
NormalizedEmail = "ADMIN@TEST.LOCAL"
|
||||
});
|
||||
var vendor = new Vendor { CompanyName = "Vinewood LLC", IsActive = true };
|
||||
// A second work order keeps ids distinct from the first, so an audit landing on
|
||||
// the wrong work order cannot pass by coincidence.
|
||||
var unrelated = new WorkOrder { InternalWONumber = "WO-OTHER", WorkerOrderTitle = "Other" };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-TARGET",
|
||||
WorkerOrderTitle = "Repair",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
context.AddRange(vendor, unrelated, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = ownership == Ownership.Owned ? workOrder.Id : null,
|
||||
DispatchNumber = "DIS-UPLIFT",
|
||||
Status = "Scheduled",
|
||||
NTEAmount = 1_000m
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
if (ownership == Ownership.Linked)
|
||||
{
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id
|
||||
});
|
||||
}
|
||||
|
||||
var request = new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1_000m,
|
||||
RequestedNTE = 5_000m,
|
||||
VendorReason = "Scope grew",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 2,
|
||||
CreatedDate = DateTime.UtcNow.AddDays(-4),
|
||||
ExpiresAt = expiresAt,
|
||||
InitialNotificationSentAt = initialNotificationSentAt,
|
||||
NotificationStatus = initialNotificationSentAt == null ? "Pending" : "Sent"
|
||||
};
|
||||
context.DispatchUpliftRequests.Add(request);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return (request.Id, ownership == Ownership.Unresolvable ? null : workOrder.Id);
|
||||
}
|
||||
|
||||
private sealed class NoDocumentStorage : IVendorDocumentStoragePort
|
||||
{
|
||||
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public void Delete(int vendorId, int dispatchId, string storedFileName)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// Records sends without delivering anything.
|
||||
private sealed class NoEmailSender : IEmailSender
|
||||
{
|
||||
public int Calls;
|
||||
|
||||
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SqliteUpliftDecisionTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteUpliftDecisionTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
// SQL Server filtered index syntax is invalid on SQLite.
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
// SQLite has no rowversion type; treat as plain nullable blobs.
|
||||
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
|
||||
{
|
||||
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue