shoc-backend/SeaHavenIndustries.Tests/UpliftDecisionAuditRelationalTests.cs

332 lines
13 KiB
C#

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