mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
332 lines
13 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|