mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
* refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
223 lines
9 KiB
C#
223 lines
9 KiB
C#
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<IWorkOrderDataService>? woData = null,
|
|
Mock<IVendorDataService>? vendorData = null,
|
|
Mock<ITaskListTemplateDataService>? templateData = null,
|
|
Mock<IDispatchDataService>? dispatchData = null)
|
|
{
|
|
var dd = dispatchData ?? new Mock<IDispatchDataService>();
|
|
dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1");
|
|
dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1");
|
|
dd.Setup(d => d.AddAsync(It.IsAny<Dispatch>()))
|
|
.ReturnsAsync((Dispatch d) => d);
|
|
|
|
var emailPort = new Mock<IDispatchEmailPort>();
|
|
emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
.ReturnsAsync(true);
|
|
|
|
var tokenPort = new Mock<IVendorTokenPort>();
|
|
tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny<int>())).ReturnsAsync("token");
|
|
|
|
return new WorkOrderDispatchService(
|
|
(woData ?? new Mock<IWorkOrderDataService>()).Object,
|
|
dd.Object,
|
|
(vendorData ?? new Mock<IVendorDataService>()).Object,
|
|
Mock.Of<IUserDataService>(),
|
|
(templateData ?? new Mock<ITaskListTemplateDataService>()).Object,
|
|
Mock.Of<ICommentDataService>(),
|
|
emailPort.Object,
|
|
tokenPort.Object,
|
|
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
|
|
}
|
|
|
|
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) =>
|
|
WorkOrderDataReturning(new Mock<IWorkOrderDataService>(), workOrders);
|
|
|
|
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(Mock<IWorkOrderDataService> mock, params WorkOrder[] workOrders)
|
|
{
|
|
mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync(
|
|
It.IsAny<IReadOnlyCollection<int>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(workOrders.ToList());
|
|
return mock;
|
|
}
|
|
|
|
private static Mock<IVendorDataService> VendorDataReturning(params Vendor[] vendors)
|
|
{
|
|
var mock = new Mock<IVendorDataService>();
|
|
mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
|
|
.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<IDispatchDataService>();
|
|
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<Dispatch>()))
|
|
.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<IVendorDataService>();
|
|
vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Vendor> { 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<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 2, 999 })),
|
|
It.IsAny<CancellationToken>()), Times.Once);
|
|
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount()
|
|
{
|
|
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
|
|
var templateData = new Mock<ITaskListTemplateDataService>();
|
|
templateData.Setup(d => d.GetByIdWithItemsAsync(7))
|
|
.ReturnsAsync(new TaskListTemplate
|
|
{
|
|
Items = new List<TaskListTemplateItem>
|
|
{
|
|
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<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), 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<IReadOnlyCollection<WorkOrderAuditLog>>(logs => logs.Count == 2)), Times.Exactly(2));
|
|
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), 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<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);
|
|
}
|
|
}
|