2026-09-17 03:47:09 -03:00
|
|
|
using Data.SeaHavenIndustries;
|
|
|
|
|
using Data.SeaHavenIndustries.Enums;
|
|
|
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using SeaHaven.DataServices.Implementation;
|
|
|
|
|
|
|
|
|
|
namespace SeaHavenIndustries.Tests;
|
|
|
|
|
|
|
|
|
|
// Relational (SQLite) coverage for the uplift queue read. The in-memory provider
|
|
|
|
|
// evaluates LINQ in memory and cannot prove the query translates to SQL, so this
|
|
|
|
|
// executes GetPagedAsync against a real relational provider and asserts a dispatch
|
|
|
|
|
// linked only through DispatchWorkOrders still resolves its work-order context and
|
|
|
|
|
// closed flag through the effective primary-plus-linked work order.
|
|
|
|
|
public sealed class UpliftQueueReadRelationalTests
|
|
|
|
|
{
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPagedAsync_ResolvesWorkOrder_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
|
|
|
|
|
{
|
|
|
|
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
|
|
|
|
await connection.OpenAsync();
|
|
|
|
|
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseSqlite(connection)
|
|
|
|
|
.Options;
|
|
|
|
|
|
|
|
|
|
await using var context = new SqliteUpliftTestDbContext(options);
|
|
|
|
|
await context.Database.EnsureCreatedAsync();
|
|
|
|
|
|
|
|
|
|
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
|
|
|
|
var workOrder = new WorkOrder
|
|
|
|
|
{
|
merge: rebase queue flags onto SH-210 decisions, drop duplicated contract fields
#144 branched from the SH-210 decision-actions commit before the SH-207/SH-208
read-contract corrections landed, so it re-implemented workOrderClosed,
attachmentCount, decidedByName, and pendingExposureTotal with stale semantics:
it projected WorkerOrderNumber (the CRM external id) instead of InternalWONumber
(what the board renders) and summed raw RequestedNTE, which double-counts the new
NTE total on vendor-portal rows across sequential approvals.
Merge origin/feat/ab/sh-210-uplift-decisions (#141, what lands) in and resolve
every conflict in #141's favour, so those fields and their granted-amount
exposure math now come from #141 rather than being duplicated here. Keep only the
two deltas #144 actually adds on top of #141:
- attachmentCount counts non-deleted UpliftEvidence documents on the dispatch,
not every completion document, so completion photos no longer inflate the chip;
- the queue read resolves the effective work order via the primary-plus-linked
(DispatchWorkOrders) convention the sibling reads use, so a dispatch linked only
through that table surfaces its WO context, closed flag, and exposure. Covered
by an in-memory test and a SQLite relational test that proves the fallback
translates to SQL.
Drop the superseded attachment-count test that asserted completion documents
count, and align the relational test to seed InternalWONumber.
2026-09-17 12:52:13 -03:00
|
|
|
// The queue renders the internal display number; WorkerOrderNumber holds the
|
|
|
|
|
// CRM external id and must not surface here.
|
|
|
|
|
InternalWONumber = "WO-LINKED",
|
|
|
|
|
WorkerOrderNumber = "CRM-EXT-LINKED",
|
2026-09-17 03:47:09 -03:00
|
|
|
SiteCode = "SITE-Z",
|
|
|
|
|
Service = "Electrical",
|
|
|
|
|
WorkerOrderTitle = "Repair",
|
|
|
|
|
LifecycleStatus = LifecycleStatus.Completed
|
|
|
|
|
};
|
|
|
|
|
context.AddRange(vendor, workOrder);
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
var dispatch = new Dispatch
|
|
|
|
|
{
|
|
|
|
|
VendorId = vendor.Id,
|
|
|
|
|
DispatchNumber = "DIS-LINKED",
|
|
|
|
|
Status = "Completed"
|
|
|
|
|
};
|
|
|
|
|
context.Dispatches.Add(dispatch);
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
|
|
|
|
{
|
|
|
|
|
DispatchId = dispatch.Id,
|
|
|
|
|
WorkOrderId = workOrder.Id
|
|
|
|
|
});
|
|
|
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
|
|
|
{
|
|
|
|
|
DispatchId = dispatch.Id,
|
|
|
|
|
Status = "Approved",
|
|
|
|
|
CreatedDate = new DateTime(2026, 3, 1),
|
|
|
|
|
DecidedAt = new DateTime(2026, 3, 2),
|
|
|
|
|
RequiredTier = 1,
|
|
|
|
|
RequestedNTE = 90m,
|
|
|
|
|
NotificationStatus = "Pending"
|
|
|
|
|
});
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
var data = new UpliftDataService(context);
|
|
|
|
|
|
|
|
|
|
var (total, items) = await data.GetPagedAsync("Approved", null, 1, 25, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(1, total);
|
|
|
|
|
var item = Assert.Single(items);
|
|
|
|
|
Assert.Equal(workOrder.Id, item.WorkOrderId);
|
|
|
|
|
Assert.Equal("WO-LINKED", item.WorkOrderNumber);
|
|
|
|
|
Assert.Equal("SITE-Z", item.WorkOrderSiteCode);
|
|
|
|
|
Assert.Equal("Electrical", item.WorkOrderService);
|
|
|
|
|
Assert.True(item.WorkOrderClosed);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:35:35 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPagedAsync_ResolvesDetailModalContext_PerAccountWorkOrder()
|
|
|
|
|
{
|
|
|
|
|
// SH-209: dispatcher (work-order assignee navigation), technician (vendor
|
|
|
|
|
// contact) and scheduled date must translate to SQL through the effective
|
|
|
|
|
// work-order subquery, and each row resolves them from its own account's
|
|
|
|
|
// work order only.
|
|
|
|
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
|
|
|
|
await connection.OpenAsync();
|
|
|
|
|
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseSqlite(connection)
|
|
|
|
|
.Options;
|
|
|
|
|
|
|
|
|
|
await using var context = new SqliteUpliftTestDbContext(options);
|
|
|
|
|
await context.Database.EnsureCreatedAsync();
|
|
|
|
|
|
|
|
|
|
var accountA = new Accounts { Name = "Account A" };
|
|
|
|
|
var accountB = new Accounts { Name = "Account B" };
|
|
|
|
|
var dispatcherA = new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" };
|
|
|
|
|
var vendorA = new Vendor { CompanyName = "Vendor A", ContactName = "Tech A", IsActive = true };
|
|
|
|
|
var vendorB = new Vendor { CompanyName = "Vendor B", ContactName = "Tech B", IsActive = true };
|
|
|
|
|
context.AddRange(accountA, accountB, dispatcherA, vendorA, vendorB);
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
var workOrderA = new WorkOrder
|
|
|
|
|
{
|
|
|
|
|
InternalWONumber = "WO-A",
|
|
|
|
|
WorkerOrderTitle = "Repair",
|
|
|
|
|
AccountId = accountA.Id,
|
|
|
|
|
AssignTo = dispatcherA.Id,
|
|
|
|
|
ScheduledDate = new DateTime(2026, 5, 1, 8, 0, 0)
|
|
|
|
|
};
|
|
|
|
|
var workOrderB = new WorkOrder
|
|
|
|
|
{
|
|
|
|
|
InternalWONumber = "WO-B",
|
|
|
|
|
WorkerOrderTitle = "Repair",
|
|
|
|
|
AccountId = accountB.Id
|
|
|
|
|
};
|
|
|
|
|
context.AddRange(workOrderA, workOrderB);
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
var dispatchA = new Dispatch { VendorId = vendorA.Id, WorkOrderId = workOrderA.Id, DispatchNumber = "DIS-A", Status = "Scheduled" };
|
|
|
|
|
var dispatchB = new Dispatch { VendorId = vendorB.Id, WorkOrderId = workOrderB.Id, DispatchNumber = "DIS-B", Status = "Scheduled" };
|
|
|
|
|
context.Dispatches.AddRange(dispatchA, dispatchB);
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
context.DispatchUpliftRequests.AddRange(
|
|
|
|
|
new DispatchUpliftRequest
|
|
|
|
|
{
|
|
|
|
|
DispatchId = dispatchA.Id,
|
|
|
|
|
Status = "Pending",
|
|
|
|
|
CreatedDate = new DateTime(2026, 4, 1),
|
|
|
|
|
RequiredTier = 1,
|
|
|
|
|
RequestedNTE = 100m,
|
|
|
|
|
NotificationStatus = "Pending"
|
|
|
|
|
},
|
|
|
|
|
new DispatchUpliftRequest
|
|
|
|
|
{
|
|
|
|
|
DispatchId = dispatchB.Id,
|
|
|
|
|
Status = "Pending",
|
|
|
|
|
CreatedDate = new DateTime(2026, 4, 2),
|
|
|
|
|
RequiredTier = 1,
|
|
|
|
|
RequestedNTE = 200m,
|
|
|
|
|
NotificationStatus = "Pending"
|
|
|
|
|
});
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
var data = new UpliftDataService(context);
|
|
|
|
|
|
|
|
|
|
var (total, items) = await data.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(2, total);
|
|
|
|
|
var rowA = Assert.Single(items, i => i.WorkOrderId == workOrderA.Id);
|
|
|
|
|
Assert.Equal("Ann", rowA.WorkOrderDispatcherFirstName);
|
|
|
|
|
Assert.Equal("Alpha", rowA.WorkOrderDispatcherLastName);
|
|
|
|
|
Assert.Equal("Tech A", rowA.TechnicianName);
|
|
|
|
|
Assert.Equal(new DateTime(2026, 5, 1, 8, 0, 0), rowA.WorkOrderScheduledDate);
|
|
|
|
|
var rowB = Assert.Single(items, i => i.WorkOrderId == workOrderB.Id);
|
|
|
|
|
Assert.Null(rowB.WorkOrderDispatcherFirstName);
|
|
|
|
|
Assert.Null(rowB.WorkOrderDispatcherLastName);
|
|
|
|
|
Assert.Equal("Tech B", rowB.TechnicianName);
|
|
|
|
|
Assert.Null(rowB.WorkOrderScheduledDate);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 03:47:09 -03:00
|
|
|
private sealed class SqliteUpliftTestDbContext : ApplicationDbContext
|
|
|
|
|
{
|
|
|
|
|
public SqliteUpliftTestDbContext(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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|