fix(architecture): address backend review follow-ups

This commit is contained in:
Alexandre Brandizzi 2026-07-24 13:16:34 -03:00
parent ce7d612ac9
commit 6f6c7e04cd
5 changed files with 78 additions and 96 deletions

View file

@ -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<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), 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<IReadOnlyCollection<WorkOrderAuditLog>>(logs => logs.Count == 2)), Times.Exactly(2));
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), Times.Never);
@ -192,8 +192,32 @@ public class DispatchBatchingBehaviorTests
{
WorkOrderIds = new() { 404 },
VendorIds = new() { 1 }
});
}, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[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<IReadOnlyCollection<int>>(),
token), Times.Once);
vendorData.Verify(d => d.GetByIdsOrderedAsync(
It.IsAny<IReadOnlyCollection<int>>(),
token), Times.Once);
}
}

View file

@ -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<IConfiguration>(configuration);
services.AddSingleton<IZipCodeDistance>(_ => new DummyZipCodeDistance());
services.AddSingleton<IEmailSender>(_ => new DummyEmailSender());
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()));
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
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<ICalendarService>().Should().BeOfType<CalendarService>();
provider.GetRequiredService<ICommonService>().Should().BeOfType<CommonService>();
provider.GetRequiredService<IDashboardService>().Should().BeOfType<DashboardService>();
provider.GetRequiredService<IDropdownOptionsService>().Should().BeOfType<DropdownOptionsService>();
provider.GetRequiredService<ITaskListTemplateService>().Should().BeOfType<TaskListTemplateService>();
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<ICalendarDataService>().Should().BeOfType<CalendarDataService>();
provider.GetRequiredService<ICommonDataService>().Should().BeOfType<CommonDataService>();
provider.GetRequiredService<IDashboardDataService>().Should().BeOfType<DashboardDataService>();
provider.GetRequiredService<IDropdownOptionsDataService>().Should().BeOfType<DropdownOptionsDataService>();
}
[Fact]
public void ConventionDi_RegistersFollowUpLocationDispatchSettingsServices()
{
using var provider = BuildProvider();
provider.GetRequiredService<IFollowUpService>().Should().BeOfType<FollowUpService>();
provider.GetRequiredService<ILocationService>().Should().BeOfType<LocationService>();
provider.GetRequiredService<IDispatchService>().Should().BeOfType<DispatchService>();
provider.GetRequiredService<ISettingsService>().Should().BeOfType<SettingsService>();
}
[Fact]
public void ConventionDi_RegistersFollowUpLocationDispatchSettingsDataServices()
{
using var provider = BuildProvider();
provider.GetRequiredService<IFollowUpDataService>().Should().BeOfType<FollowUpDataService>();
provider.GetRequiredService<ILocationDataService>().Should().BeOfType<LocationDataService>();
provider.GetRequiredService<IDispatchDataService>().Should().BeOfType<DispatchDataService>();
provider.GetRequiredService<ISettingsDataService>().Should().BeOfType<SettingsDataService>();
}
[Fact]
public void ConventionDi_RegistersVendorAndUpliftServices()
{
using var provider = BuildProvider();
provider.GetRequiredService<IVendorService>().Should().BeOfType<VendorService>();
provider.GetRequiredService<IVendorPortalService>().Should().BeOfType<VendorPortalService>();
provider.GetRequiredService<IVendorPortalTokenService>()
.Should().BeOfType<SeaHaven.Services.Implementation.VendorPortalTokenService>();
provider.GetRequiredService<IUpliftService>().Should().BeOfType<UpliftService>();
}
[Fact]
public void ConventionDi_RegistersVendorAndUpliftDataServices()
{
using var provider = BuildProvider();
provider.GetRequiredService<IVendorDataService>().Should().BeOfType<VendorDataService>();
provider.GetRequiredService<IUpliftDataService>().Should().BeOfType<UpliftDataService>();
provider.GetRequiredService<ICommentDataService>().Should().BeOfType<CommentDataService>();
}
private sealed class DummyZipCodeDistance : IZipCodeDistance
{
public double? GetDistanceMiles(string? zip1, string? zip2) => null;
}
private sealed class DummyEmailSender : IEmailSender
{
public Task<bool> SendEmailAsync(string emailTo, string subject, string body) =>
Task.FromResult(true);
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices");
}
}

View file

@ -342,7 +342,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost]
[Route("DispatchToVendor")]
public async Task<IActionResult> DispatchToVendor([FromBody] Dispatch_DTO model)
public async Task<IActionResult> 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)

View file

@ -43,11 +43,11 @@ namespace SeaHaven.Services.Implementation
_frontendOptions = frontendOptions.Value;
}
public async Task<DispatchToVendorResult> DispatchToVendorAsync(DispatchToVendorInput input)
public async Task<DispatchToVendorResult> 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<DispatchResultItem>();

View file

@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderDispatchService
{
Task<DispatchToVendorResult> DispatchToVendorAsync(DispatchToVendorInput input);
Task<DispatchToVendorResult> DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken);
Task<IEnumerable<DispatchReadModel>> GetDispatchesAsync(int workOrderId);
Task<DispatchDetailReadModel?> GetDispatchDetailAsync(int id);
Task<bool> UpdateDispatchAsync(UpdateDispatchInput input);