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().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().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.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().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.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 SendEmailAsync(string emailTo, string subject, string body) { Calls++; return Task.FromResult(true); } } private sealed class SqliteUpliftDecisionTestDbContext : ApplicationDbContext { public SqliteUpliftDecisionTestDbContext(DbContextOptions 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; } } } }