shoc-backend/Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs
Alexandre Brandizzi 7d245eb717
refactor: enforce backend boundaries and optimize dispatch (#30)
* 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
2026-07-24 17:35:34 -03:00

117 lines
5 KiB
C#

using FluentAssertions;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DispatchServiceTests
{
private static DispatchService NewService(Mock<IDispatchDataService> data) =>
new(data.Object, new CreateDispatchValidation(), new UpdateDispatchValidation());
[Fact]
public async Task GetDispatchListAsync_MapsDataReadModelToServiceDtoPreservingFields()
{
var data = new Mock<IDispatchDataService>();
var source = new DispatchListResult
{
Total = 1,
Page = 2,
PageSize = 25,
Items = new List<DispatchListItemDTO>
{
new()
{
Id = 7,
DispatchNumber = "DISP-7",
PONumber = "PO-1",
NTEAmount = 200m,
Status = "Pending",
VendorId = 9,
VendorName = "Acme",
WorkOrderId = 3,
PrimaryWOInternalNumber = "WO-3",
PrimaryWOTitle = "Fix AC",
LocationName = "Site A",
WorkOrderCount = 2,
PendingUpliftCount = 1
}
}
};
data.Setup(d => d.GetListAsync(It.IsAny<DispatchListQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
var result = await NewService(data).GetDispatchListAsync("a", "Pending", 9, null, null, "ponumber", "asc", 2, 25, CancellationToken.None);
result.Should().BeOfType<DispatchListResultDTO>();
result.Total.Should().Be(1);
result.Page.Should().Be(2);
result.PageSize.Should().Be(25);
var row = result.Items.Single();
row.Id.Should().Be(7);
row.DispatchNumber.Should().Be("DISP-7");
row.PONumber.Should().Be("PO-1");
row.NTEAmount.Should().Be(200m);
row.Status.Should().Be("Pending");
row.VendorId.Should().Be(9);
row.VendorName.Should().Be("Acme");
row.WorkOrderId.Should().Be(3);
row.PrimaryWOInternalNumber.Should().Be("WO-3");
row.PrimaryWOTitle.Should().Be("Fix AC");
row.LocationName.Should().Be("Site A");
row.WorkOrderCount.Should().Be(2);
row.PendingUpliftCount.Should().Be(1);
}
[Fact]
public async Task GetDispatchDetailAsync_MapsNestedCollectionsPreservingShape()
{
var data = new Mock<IDispatchDataService>();
var detail = new DispatchDetailDTO
{
Id = 42,
DispatchNumber = "DISP-42",
PONumber = "PO-9",
NTEAmount = 500m,
Status = "Dispatched",
Description = "desc",
WorkOrderId = 11,
EmailSent = true,
Vendor = new DispatchDetailVendorDTO { Id = 1, CompanyName = "VendorCo", Email = "v@x.com" },
PrimaryLocation = new DispatchPrimaryLocationDTO { Id = 5, Name = "Loc", City = "Austin" },
WorkOrders = new List<DispatchLinkedWorkOrderDTO> { new() { WorkOrderId = 11, Title = "T", Priority = "High" } },
Checklist = new List<DispatchChecklistDTO> { new() { Id = 1, ItemText = "Check", IsCompleted = true, SortOrder = 1 } },
Signoffs = new List<DispatchSignoffDTO> { new() { Id = 1, SignoffType = "Vendor", Name = "J" } },
Comments = new List<DispatchCommentDTO> { new() { Id = 1, Commenttext = "Hi", UserName = "Sara" } },
UpliftRequests = new List<DispatchUpliftRequestDTO> { new() { Id = 1, RequestedNTE = 600m, CurrentNTE = 500m, Delta = 100m, Status = "Pending" } },
AuditLog = new List<DispatchAuditLogDTO> { new() { Id = 1, Action = "Updated", FieldName = "DISP-42:Status" } }
};
data.Setup(d => d.GetDetailAsync(42, It.IsAny<CancellationToken>())).ReturnsAsync(detail);
data.Setup(d => d.GetDetailAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((DispatchDetailDTO?)null);
var service = NewService(data);
var found = await service.GetDispatchDetailAsync(42, CancellationToken.None);
var missing = await service.GetDispatchDetailAsync(999, CancellationToken.None);
missing.Should().BeNull();
found.Should().BeOfType<DispatchDetailResultDTO>();
found!.Id.Should().Be(42);
found.DispatchNumber.Should().Be("DISP-42");
found.EmailSent.Should().BeTrue();
found.Vendor!.CompanyName.Should().Be("VendorCo");
found.PrimaryLocation!.Name.Should().Be("Loc");
found.WorkOrders.Single().Title.Should().Be("T");
found.Checklist.Single().IsCompleted.Should().BeTrue();
found.Signoffs.Single().Name.Should().Be("J");
found.Comments.Single().UserName.Should().Be("Sara");
found.UpliftRequests.Single().Delta.Should().Be(100m);
found.AuditLog.Single().Action.Should().Be("Updated");
}
}