shoc-backend/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.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

281 lines
12 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
internal static class TestAsyncEnumerableHelper
{
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
{
foreach (var item in source)
yield return item;
await Task.CompletedTask;
}
}
public class SyncServiceBehaviorTests
{
[Fact]
public async Task SyncWorkOrders_CreatesNewAndUpdatesExisting()
{
var data = new Mock<ISyncDataService>();
var external = new Mock<ISyncExternalSource>();
var existingWO = new WorkOrder { Id = 5, ExternalWorkOrderId = "EXT-1", WorkerOrderTitle = "Old" };
data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync("10000005");
data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(existingWO);
data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-2", It.IsAny<CancellationToken>()))
.ReturnsAsync((WorkOrder?)null);
data.Setup(d => d.ResolveLocationAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((int?)null);
data.Setup(d => d.GetLocationCountAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(3);
var items = new List<Dictionary<string, string?>>
{
new() { ["work_order_id"] = "EXT-1", ["description"] = "Updated Title", ["wo_status"] = "in_progress" },
new() { ["work_order_id"] = "EXT-2", ["description"] = "New WO", ["wo_status"] = "new", ["severity"] = "3" }
};
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>()))
.Returns(items.ToAsyncEnumerable());
var service = new SyncService(data.Object, external.Object);
var result = await service.SyncWorkOrdersAsync(CancellationToken.None);
Assert.Equal(2, result.Synced);
Assert.Equal(1, result.Created);
Assert.Equal(1, result.Updated);
Assert.Equal(3, result.LocationsCreated);
Assert.Equal("Updated Title", existingWO.WorkerOrderTitle);
Assert.Equal("In Progress", existingWO.Status);
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once);
}
[Fact]
public async Task SyncWorkOrders_MapsStatusAndSeverity()
{
var data = new Mock<ISyncDataService>();
var external = new Mock<ISyncExternalSource>();
data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny<CancellationToken>())).ReturnsAsync((string?)null);
data.Setup(d => d.GetWorkOrderByExternalIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((WorkOrder?)null);
data.Setup(d => d.ResolveLocationAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync((int?)null);
data.Setup(d => d.GetLocationCountAsync(It.IsAny<CancellationToken>())).ReturnsAsync(0);
var items = new List<Dictionary<string, string?>>
{
new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2" }
};
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None);
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once);
}
[Fact]
public async Task SyncComments_SkipsExistingAndOrphans()
{
var data = new Mock<ISyncDataService>();
var external = new Mock<ISyncExternalSource>();
var wo = new WorkOrder { Id = 10, ExternalWorkOrderId = "WO-1" };
data.Setup(d => d.CommentExistsByExternalIdAsync("C-EXISTS", It.IsAny<CancellationToken>())).ReturnsAsync(true);
data.Setup(d => d.CommentExistsByExternalIdAsync("C-NEW", It.IsAny<CancellationToken>())).ReturnsAsync(false);
data.Setup(d => d.CommentExistsByExternalIdAsync("C-ORPHAN", It.IsAny<CancellationToken>())).ReturnsAsync(false);
data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-1", It.IsAny<CancellationToken>())).ReturnsAsync(wo);
data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-MISSING", It.IsAny<CancellationToken>())).ReturnsAsync((WorkOrder?)null);
var items = new List<Dictionary<string, string?>>
{
new() { ["comment_id"] = "C-EXISTS", ["work_order_id"] = "WO-1" },
new() { ["comment_id"] = "C-NEW", ["work_order_id"] = "WO-1", ["text"] = "Hello" },
new() { ["comment_id"] = "C-ORPHAN", ["work_order_id"] = "WO-MISSING" }
};
external.Setup(e => e.ScanCommentsAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
var result = await new SyncService(data.Object, external.Object).SyncCommentsAsync(CancellationToken.None);
Assert.Equal(2, result.Synced);
Assert.Equal(1, result.Created);
Assert.Equal(2, result.Skipped);
data.Verify(d => d.EnqueueComment(It.Is<Comments>(c => c.ExternalCommentId == "C-NEW")), Times.Once);
}
}
public class WorkOrderDispatchServiceBehaviorTests
{
[Fact]
public async Task VerifyDispatch_ThrowsWhenChecklistIncomplete()
{
var dispatchData = CreateVerifyDispatchMocks(
out var dispatch,
incompleteCount: 1,
checklistComplete: false,
hasCustomerSignoff: true,
hasVendorSignoff: true);
dispatch.Id = 1;
var service = CreateDispatchService(dispatchData);
var ex = await Assert.ThrowsAsync<DispatchVerificationException>(() => service.VerifyDispatchAsync(1, "user1"));
Assert.Contains("1 checklist item(s) incomplete", ex.Missing);
}
[Fact]
public async Task VerifyDispatch_ThrowsWhenVendorSignoffMissing()
{
var dispatchData = CreateVerifyDispatchMocks(
out var dispatch,
incompleteCount: 0,
checklistComplete: true,
hasCustomerSignoff: true,
hasVendorSignoff: false);
dispatch.Id = 1;
var service = CreateDispatchService(dispatchData);
var ex = await Assert.ThrowsAsync<DispatchVerificationException>(() => service.VerifyDispatchAsync(1, "user1"));
Assert.Contains("Missing Vendor sign-off", ex.Missing);
}
[Fact]
public async Task VerifyDispatch_SucceedsWhenAllComplete()
{
var dispatchData = CreateVerifyDispatchMocks(
out var dispatch,
incompleteCount: 0,
checklistComplete: true,
hasCustomerSignoff: true,
hasVendorSignoff: true);
dispatch.Id = 1;
var userMock = new Mock<IUserDataService>();
userMock.Setup(u => u.GetByIdAsync("user1")).ReturnsAsync(new ApplicationUser { FirstName = "Jane", LastName = "Doe" });
var service = CreateDispatchService(dispatchData, userMock: userMock);
var result = await service.VerifyDispatchAsync(1, "user1");
Assert.NotNull(result);
Assert.Equal("Dispatch verified", result!.Message);
Assert.Equal("Verified", dispatch.Status);
Assert.Equal("Jane Doe", dispatch.VerifiedBy);
}
private static Mock<IDispatchDataService> CreateVerifyDispatchMocks(
out Dispatch dispatch,
int incompleteCount,
bool checklistComplete,
bool hasCustomerSignoff,
bool hasVendorSignoff)
{
dispatch = new Dispatch { Id = 1, DispatchNumber = "DSP-00001", WorkOrderId = 10 };
var mock = new Mock<IDispatchDataService>();
mock.Setup(d => d.GetByIdAsync(1)).ReturnsAsync(dispatch);
var checklist = checklistComplete
? new List<DispatchChecklistItem> { new() { IsCompleted = true } }
: new List<DispatchChecklistItem> { new() { IsCompleted = false } };
mock.Setup(d => d.GetChecklistItemsAsync(1)).ReturnsAsync(checklist);
var signoffs = new List<DispatchSignoff>();
if (hasCustomerSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Customer" });
if (hasVendorSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Vendor" });
mock.Setup(d => d.GetSignoffsAsync(1)).ReturnsAsync(signoffs);
return mock;
}
private static WorkOrderDispatchService CreateDispatchService(
Mock<IDispatchDataService>? dispatchDataMock = null,
Mock<IUserDataService>? userMock = null)
{
return new WorkOrderDispatchService(
Mock.Of<IWorkOrderDataService>(),
(dispatchDataMock ?? new Mock<IDispatchDataService>()).Object,
Mock.Of<IVendorDataService>(),
(userMock ?? new Mock<IUserDataService>()).Object,
Mock.Of<ITaskListTemplateDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IDispatchEmailPort>(),
Mock.Of<IVendorTokenPort>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
}
}
public class WorkOrderServiceBehaviorTests
{
[Fact]
public async Task ChangeStatus_AddsAuditWhenStatusDiffers()
{
var wo = new WorkOrder { Id = 5, Status = "Open" };
var woData = new Mock<IWorkOrderDataService>();
woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo);
var service = CreateWorkOrderService(woData);
var result = await service.ChangeStatusAsync(5, "In Progress", "user1");
Assert.NotNull(result);
Assert.Equal("In Progress", wo.Status);
woData.Verify(d => d.AddAuditLogAsync(It.Is<WorkOrderAuditLog>(a =>
a.FieldName == "Status" && a.OldValue == "Open" && a.NewValue == "In Progress")), Times.Once);
}
[Fact]
public async Task ChangeStatus_DoesNotAuditWhenStatusSame()
{
var wo = new WorkOrder { Id = 5, Status = "Open" };
var woData = new Mock<IWorkOrderDataService>();
woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo);
await CreateWorkOrderService(woData).ChangeStatusAsync(5, "Open", "user1");
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), Times.Never);
}
[Fact]
public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesComments()
{
var woData = new Mock<IWorkOrderDataService>();
woData.Setup(d => d.ExistsAsync(5)).ReturnsAsync(true);
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetByWorkOrderIdAsync(5))
.ReturnsAsync(new List<Comments> { new() { Id = 1 }, new() { Id = 2 } });
var quotesData = new Mock<IQuotesDataService>();
var result = await CreateWorkOrderService(woData, commentData, quotesData).DeleteWorkOrderCascadeAsync(5, "user1");
Assert.True(result);
quotesData.Verify(q => q.UnlinkFromWorkOrderAsync(5), Times.Once);
commentData.Verify(c => c.DeleteAsync(1), Times.Once);
commentData.Verify(c => c.DeleteAsync(2), Times.Once);
}
private static WorkOrderService CreateWorkOrderService(
Mock<IWorkOrderDataService>? woData = null,
Mock<ICommentDataService>? commentData = null,
Mock<IQuotesDataService>? quotesData = null)
{
return new WorkOrderService(
(woData ?? new Mock<IWorkOrderDataService>()).Object,
(commentData ?? new Mock<ICommentDataService>()).Object,
Mock.Of<IUserDataService>(),
(quotesData ?? new Mock<IQuotesDataService>()).Object,
Mock.Of<IFileStoragePort>(),
Mock.Of<ICreateWorkOrderValidation>(),
Mock.Of<IUpdateWorkOrderValidation>());
}
}