Merge pull request #177 from Sea-Haven-Industries/fix/ab/sh-397-uplift-decision-audit
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions

fix(uplifts): let admins decide uplifts whose dispatch has no work order
This commit is contained in:
Alexandre Brandizzi 2026-09-25 02:45:18 +00:00 • committed by GitHub
commit 50e5553e57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 371 additions and 10 deletions

View file

@ -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);

View file

@ -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);
}
}
}

View file

@ -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;

View 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;
}
}
}
}