using System.Collections; using System.Security.Claims; using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class VendorOperationsControllerTests { private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static VendorOperationsController NewController(ApplicationDbContext context) { var dataService = new VendorOperationsDataService(context, new DispatchDataService(context)); var service = new VendorOperationsService(dataService, TimeProvider.System); var controller = new VendorOperationsController(service); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test")) } }; return controller; } private static object Prop(object source, string name) => source.GetType().GetProperty(name)!.GetValue(source)!; [Fact] public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours() { using var context = NewContext(); context.workOrders.Add(new WorkOrder { InternalWONumber = "WO-48", WorkerOrderTitle = "Needs vendor", ScheduledDate = DateTime.UtcNow.AddHours(47) }); await context.SaveChangesAsync(); var result = await NewController(context).GetNotifications(CancellationToken.None); var value = ((OkObjectResult)result).Value!; var items = ((IEnumerable)Prop(value, "Items")).Cast().ToList(); items.Should().ContainSingle(); Prop(items[0], "Type").Should().Be("NoVendor"); Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service"); } [Fact] public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; var workOrder = new WorkOrder { InternalWONumber = "WO-ASSIGNED", ScheduledDate = DateTime.UtcNow.AddHours(24) }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledDate }); await context.SaveChangesAsync(); var result = await NewController(context).GetNotifications(CancellationToken.None); var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast(); items.Should().BeEmpty(); } [Fact] public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst() { using var context = NewContext(); var site = new Locations { Name = "St Louis" }; var preferred = new Vendor { CompanyName = "Preferred", ContactName = "Alex", IsActive = true, TradeSpecialties = "HVAC", AvailabilityStatus = "Available", AvailabilityUpdatedAt = DateTime.UtcNow }; var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true }; var workOrder = new WorkOrder { InternalWONumber = "WO-BUSY", ScheduledStart = DateTime.UtcNow.AddHours(1), ScheduledEnd = DateTime.UtcNow.AddHours(3) }; context.AddRange(site, preferred, busy, workOrder); await context.SaveChangesAsync(); context.SitePreferredVendors.Add(new SitePreferredVendor { LocationId = site.Id, VendorId = preferred.Id, Trade = "HVAC", SortOrder = 0 }); context.Dispatches.Add(new Dispatch { VendorId = busy.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledStart }); await context.SaveChangesAsync(); var result = await NewController(context).GetAvailability( DateTime.UtcNow.AddHours(2), DateTime.UtcNow.AddHours(4), site.Id, "HVAC", CancellationToken.None); var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast().ToList(); Prop(rows[0], "CompanyName").Should().Be("Preferred"); Prop(rows[0], "IsPreferred").Should().Be(true); var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy"); Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable"); } [Fact] public async Task ReplaceSitePreferences_RejectsInactiveVendor() { using var context = NewContext(); var site = new Locations { Name = "St Louis" }; var inactive = new Vendor { CompanyName = "Inactive", IsActive = false }; context.AddRange(site, inactive); await context.SaveChangesAsync(); var result = await NewController(context).ReplaceSitePreferences(site.Id, new List { new() { VendorId = inactive.Id } }, CancellationToken.None); result.Should().BeOfType(); context.SitePreferredVendors.Should().BeEmpty(); } [Fact] public async Task InsightsExports_ReturnStableCsvAndPdfFormats() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false }; var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Completed", DispatchedAt = DateTime.UtcNow.AddHours(-4), ScheduledDate = DateTime.UtcNow.AddHours(-2), AcknowledgedAt = DateTime.UtcNow.AddHours(-3), CompletedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var controller = NewController(context); var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null, CancellationToken.None); var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null, CancellationToken.None); csv.ContentType.Should().Be("text/csv"); var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents); csvText.Should().Contain("AverageCycleHours"); csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\""); pdf.ContentType.Should().Be("application/pdf"); System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4"); } [Fact] public async Task AssignVendor_RejectsInactiveVendor() { using var context = NewContext(); var vendor = new Vendor { CompanyName = "Inactive", IsActive = false }; var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; context.AddRange(vendor, workOrder); await context.SaveChangesAsync(); var result = await NewController(context).AssignVendor( workOrder.Id, new VendorAssignmentRequest { VendorId = vendor.Id }, CancellationToken.None); result.Should().BeOfType(); context.Dispatches.Should().BeEmpty(); } [Fact] public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts() { using var context = NewContext(); var start = DateTime.UtcNow.AddHours(2); var vendor = new Vendor { CompanyName = "Gateway", IsActive = true, AvailabilityStatus = "Available", AvailabilityUpdatedAt = DateTime.UtcNow }; var existingWorkOrder = new WorkOrder { InternalWONumber = "WO-EXISTING", ScheduledStart = start, ScheduledEnd = start.AddHours(2) }; var assignedWorkOrder = new WorkOrder { InternalWONumber = "WO-NEW", ScheduledStart = start.AddMinutes(30), ScheduledEnd = start.AddHours(1) }; context.AddRange(vendor, existingWorkOrder, assignedWorkOrder); await context.SaveChangesAsync(); context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = existingWorkOrder.Id, Status = "Sent", ScheduledDate = existingWorkOrder.ScheduledStart }); await context.SaveChangesAsync(); var eta = start.AddMinutes(45); var result = await NewController(context).AssignVendor( assignedWorkOrder.Id, new VendorAssignmentRequest { VendorId = vendor.Id, ScheduledStart = assignedWorkOrder.ScheduledStart, ScheduledEnd = assignedWorkOrder.ScheduledEnd, EstimatedArrivalAt = eta, EtaManualOverride = true }, CancellationToken.None); var value = ((OkObjectResult)result).Value!; Prop(value, "AvailabilityStatus").Should().Be("Unavailable"); ((IEnumerable)Prop(value, "Conflicts")).Cast().Should().ContainSingle(); var dispatch = await context.Dispatches.SingleAsync(item => item.WorkOrderId == assignedWorkOrder.Id); dispatch.EstimatedArrivalAt.Should().Be(eta); dispatch.EtaManualOverride.Should().BeTrue(); dispatch.DispatchNumber.Should().StartWith("DISP-"); context.WorkOrderAuditLogs.Should().ContainSingle(log => log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned"); } [Fact] public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin() { using var context = NewContext(); var originalVendor = new Vendor { CompanyName = "Original", IsActive = true }; var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true }; var workOrder = new WorkOrder { InternalWONumber = "WO-LINKED", ScheduledStart = DateTime.UtcNow.AddHours(2), ScheduledEnd = DateTime.UtcNow.AddHours(4) }; context.AddRange(originalVendor, replacementVendor, workOrder); await context.SaveChangesAsync(); var dispatch = new Dispatch { VendorId = originalVendor.Id, Status = "Sent", DispatchNumber = "DISP-LINKED" }; context.Dispatches.Add(dispatch); await context.SaveChangesAsync(); context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = dispatch.Id, WorkOrderId = workOrder.Id }); await context.SaveChangesAsync(); var result = await NewController(context).AssignVendor( workOrder.Id, new VendorAssignmentRequest { VendorId = replacementVendor.Id, ScheduledStart = workOrder.ScheduledStart, ScheduledEnd = workOrder.ScheduledEnd }, CancellationToken.None); result.Should().BeOfType(); context.Dispatches.Should().ContainSingle(); (await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id); } }