diff --git a/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs index 8992315..97de3a7 100644 --- a/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs +++ b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs @@ -82,7 +82,7 @@ public class DispatchBatchingBehaviorTests { WorkOrderIds = new() { 30, 10 }, VendorIds = new() { 1 } - }); + }, CancellationToken.None); saved.Should().NotBeNull(); saved!.WorkOrderId.Should().Be(30); @@ -102,7 +102,7 @@ public class DispatchBatchingBehaviorTests { WorkOrderIds = new() { 10 }, VendorIds = new() { 2, 2, 999 } - }); + }, CancellationToken.None); result.Dispatches.Should().HaveCount(1); result.Dispatches.Single().VendorName.Should().Be("Only Co"); @@ -137,7 +137,7 @@ public class DispatchBatchingBehaviorTests WorkOrderIds = new() { 10 }, VendorIds = new() { 1, 2 }, TaskListTemplateId = 7 - }); + }, CancellationToken.None); templateData.Verify(d => d.GetByIdWithItemsAsync(7), Times.Once); } @@ -154,7 +154,7 @@ public class DispatchBatchingBehaviorTests { 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); @@ -176,7 +176,7 @@ public class DispatchBatchingBehaviorTests { 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); @@ -192,8 +192,32 @@ public class DispatchBatchingBehaviorTests { 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); + } } diff --git a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs index ad81b94..84da086 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs @@ -1,117 +1,75 @@ -using Data.SeaHavenIndustries; +using System.Reflection; using FluentAssertions; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.DataServices.Implementation; -using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.Implementation; -using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class ServiceRegistrationTests { - private static ServiceProvider BuildProvider() + private static IServiceCollection BuildConventionServices() { var configuration = new ConfigurationBuilder().Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(configuration); - services.AddSingleton(_ => new DummyZipCodeDistance()); - services.AddSingleton(_ => new DummyEmailSender()); - - services.AddDbContext(options => - options.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())); - - services.AddIdentity() - .AddEntityFrameworkStores(); - services.AddDataServices(); services.AddBusinessServices(configuration); + return services; + } - return services.BuildServiceProvider(); + private static IEnumerable<(Type Implementation, Type Interface)> ConventionCandidates(Assembly assembly) + { + return assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType) + .Select(t => ( + Implementation: t, + Interface: t.GetInterfaces().FirstOrDefault(i => i.Name == $"I{t.Name}"))) + .Where(pair => pair.Interface is not null)!; + } + + private static void AssertScopedConventionRegistrations( + IServiceCollection services, + IEnumerable<(Type Implementation, Type Interface)> candidates, + string assemblyLabel) + { + var candidateList = candidates.ToList(); + candidateList.Should().NotBeEmpty( + "expected at least one convention candidate in {0}", assemblyLabel); + + var missing = candidateList + .Where(pair => !services.Any(d => + d.Lifetime == ServiceLifetime.Scoped && + d.ServiceType == pair.Interface && + d.ImplementationType == pair.Implementation)) + .ToList(); + + missing.Should().BeEmpty( + "expected a scoped descriptor mapping each convention candidate interface to its implementation in {0}, missing: {1}", + assemblyLabel, + string.Join("; ", missing.Select(p => $"{p.Interface!.FullName} -> {p.Implementation.FullName}"))); } [Fact] - public void ConventionDi_RegistersAllFiveFeatureServices() + public void ConventionDi_RegistersEveryServicesImplementationClassAsScoped() { - using var provider = BuildProvider(); + var services = BuildConventionServices(); + var candidates = ConventionCandidates(typeof(WorkOrderDispatchService).Assembly); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); + AssertScopedConventionRegistrations(services, candidates, "SeaHaven.Services"); } [Fact] - public void ConventionDi_RegistersFeatureDataServices() + public void ConventionDi_RegistersEveryDataServicesImplementationClassAsScoped() { - using var provider = BuildProvider(); + var services = BuildConventionServices(); + var candidates = ConventionCandidates(typeof(VendorDataService).Assembly); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - } - - [Fact] - public void ConventionDi_RegistersFollowUpLocationDispatchSettingsServices() - { - using var provider = BuildProvider(); - - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - } - - [Fact] - public void ConventionDi_RegistersFollowUpLocationDispatchSettingsDataServices() - { - using var provider = BuildProvider(); - - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - } - - [Fact] - public void ConventionDi_RegistersVendorAndUpliftServices() - { - using var provider = BuildProvider(); - - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService() - .Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - } - - [Fact] - public void ConventionDi_RegistersVendorAndUpliftDataServices() - { - using var provider = BuildProvider(); - - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - provider.GetRequiredService().Should().BeOfType(); - } - - private sealed class DummyZipCodeDistance : IZipCodeDistance - { - public double? GetDistanceMiles(string? zip1, string? zip2) => null; - } - - private sealed class DummyEmailSender : IEmailSender - { - public Task SendEmailAsync(string emailTo, string subject, string body) => - Task.FromResult(true); + AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices"); } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index d7d6c12..e8b5c02 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -342,7 +342,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] [Route("DispatchToVendor")] - public async Task DispatchToVendor([FromBody] Dispatch_DTO model) + public async Task DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken) { try { @@ -360,7 +360,7 @@ namespace Api.SeaHavenIndustries.Controllers UserId = userId }; - var result = await _dispatchService.DispatchToVendorAsync(input); + var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken); return Ok(result); } catch (Exception ex) diff --git a/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs index 1b906a6..c0e51a7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs @@ -43,11 +43,11 @@ namespace SeaHaven.Services.Implementation _frontendOptions = frontendOptions.Value; } - public async Task DispatchToVendorAsync(DispatchToVendorInput input) + public async Task DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken) { var workOrders = await _workOrderDataService.GetByRequestedIdsWithDetailsAsync( input.WorkOrderIds, - CancellationToken.None); + cancellationToken); if (workOrders.Count == 0) throw new InvalidOperationException("No work orders found"); @@ -66,7 +66,7 @@ namespace SeaHaven.Services.Implementation var templateItemTexts = await LoadTemplateItemTextsAsync(input.TaskListTemplateId); var distinctVendorIds = input.VendorIds.Distinct().ToList(); - var vendors = await _vendorDataService.GetByIdsOrderedAsync(distinctVendorIds, CancellationToken.None); + var vendors = await _vendorDataService.GetByIdsOrderedAsync(distinctVendorIds, cancellationToken); var dispatches = new List(); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs index d69b2c7..f5d0ae3 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs @@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces { public interface IWorkOrderDispatchService { - Task DispatchToVendorAsync(DispatchToVendorInput input); + Task DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken); Task> GetDispatchesAsync(int workOrderId); Task GetDispatchDetailAsync(int id); Task UpdateDispatchAsync(UpdateDispatchInput input);