using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class DispatchBatchingBehaviorTests { private static WorkOrderDispatchService BuildService( Mock? woData = null, Mock? vendorData = null, Mock? templateData = null, Mock? dispatchData = null) { var dd = dispatchData ?? new Mock(); dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1"); dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1"); dd.Setup(d => d.AddAsync(It.IsAny())) .ReturnsAsync((Dispatch d) => d); var emailPort = new Mock(); emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); var tokenPort = new Mock(); tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny())).ReturnsAsync("token"); return new WorkOrderDispatchService( (woData ?? new Mock()).Object, dd.Object, (vendorData ?? new Mock()).Object, Mock.Of(), (templateData ?? new Mock()).Object, Mock.Of(), emailPort.Object, tokenPort.Object, Microsoft.Extensions.Options.Options.Create(new FrontendOptions())); } private static Mock WorkOrderDataReturning(params WorkOrder[] workOrders) => WorkOrderDataReturning(new Mock(), workOrders); private static Mock WorkOrderDataReturning(Mock mock, params WorkOrder[] workOrders) { mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync( It.IsAny>(), It.IsAny())) .ReturnsAsync(workOrders.ToList()); return mock; } private static Mock VendorDataReturning(params Vendor[] vendors) { var mock = new Mock(); mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync(vendors.ToList()); return mock; } [Fact] public async Task Dispatch_PrimaryWorkOrderFollowsReturnedOrder() { var woData = WorkOrderDataReturning( new WorkOrder { Id = 30, InternalWONumber = "WO-30" }, new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); var dispatchData = new Mock(); var service = BuildService(woData, VendorDataReturning(new Vendor { Id = 1, CompanyName = "V", Email = "v@x.io" }), dispatchData: dispatchData); Dispatch? saved = null; dispatchData.Setup(d => d.AddAsync(It.IsAny())) .ReturnsAsync((Dispatch d) => { saved = d; return d; }); await service.DispatchToVendorAsync(new DispatchToVendorInput { WorkOrderIds = new() { 30, 10 }, VendorIds = new() { 1 } }, CancellationToken.None); saved.Should().NotBeNull(); saved!.WorkOrderId.Should().Be(30); } [Fact] public async Task Dispatch_DeduplicatesVendorsAndSkipsMissing() { var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); var vendorData = new Mock(); vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync(new List { new() { Id = 2, CompanyName = "Only Co", Email = "v@x.io" } }); var service = BuildService(woData, vendorData); var result = await service.DispatchToVendorAsync(new DispatchToVendorInput { WorkOrderIds = new() { 10 }, VendorIds = new() { 2, 2, 999 } }, CancellationToken.None); result.Dispatches.Should().HaveCount(1); result.Dispatches.Single().VendorName.Should().Be("Only Co"); vendorData.Verify(d => d.GetByIdsOrderedAsync( It.Is>(ids => ids.SequenceEqual(new[] { 2, 999 })), It.IsAny()), Times.Once); vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), Times.Never); } [Fact] public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount() { var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); var templateData = new Mock(); templateData.Setup(d => d.GetByIdWithItemsAsync(7)) .ReturnsAsync(new TaskListTemplate { Items = new List { new() { ItemText = "A", SortOrder = 0 }, new() { ItemText = "B", SortOrder = 1 } } }); var vendorData = VendorDataReturning( new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }, new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" }); var service = BuildService(woData, vendorData, templateData); await service.DispatchToVendorAsync(new DispatchToVendorInput { WorkOrderIds = new() { 10 }, VendorIds = new() { 1, 2 }, TaskListTemplateId = 7 }, CancellationToken.None); templateData.Verify(d => d.GetByIdWithItemsAsync(7), Times.Once); } [Fact] public async Task Dispatch_PerformsSingleBatchedVendorLookup() { var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }); var service = BuildService(woData, vendorData); await service.DispatchToVendorAsync(new DispatchToVendorInput { WorkOrderIds = new() { 10 }, VendorIds = new() { 1, 2, 3 } }, CancellationToken.None); vendorData.Verify(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny()), Times.Once); vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), Times.Never); } [Fact] public async Task Dispatch_BatchesAuditLogsOncePerVendorAndNeverSingles() { var woData = WorkOrderDataReturning( new WorkOrder { Id = 10, InternalWONumber = "WO-10" }, new WorkOrder { Id = 11, InternalWONumber = "WO-11" }); var vendorData = VendorDataReturning( new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }, new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" }); var service = BuildService(woData, vendorData); await service.DispatchToVendorAsync(new DispatchToVendorInput { WorkOrderIds = new() { 10, 11 }, VendorIds = new() { 1, 2 } }, CancellationToken.None); woData.Verify(d => d.AddAuditLogsAsync(It.Is>(logs => logs.Count == 2)), Times.Exactly(2)); woData.Verify(d => d.AddAuditLogAsync(It.IsAny()), Times.Never); } [Fact] public async Task Dispatch_NoWorkOrders_Throws() { var woData = WorkOrderDataReturning(); var service = BuildService(woData, VendorDataReturning()); var act = () => service.DispatchToVendorAsync(new DispatchToVendorInput { WorkOrderIds = new() { 404 }, VendorIds = new() { 1 } }, CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task Dispatch_ForwardsSameCancellationTokenToBothBatchDataServices() { var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }); var service = BuildService(woData, vendorData); using var cts = new CancellationTokenSource(); var token = cts.Token; await service.DispatchToVendorAsync(new DispatchToVendorInput { WorkOrderIds = new() { 10 }, VendorIds = new() { 1 } }, token); woData.Verify(d => d.GetByRequestedIdsWithDetailsAsync( It.IsAny>(), token), Times.Once); vendorData.Verify(d => d.GetByIdsOrderedAsync( It.IsAny>(), token), Times.Once); } }