mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 17:43: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
353 lines
12 KiB
C#
353 lines
12 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SeaHaven.DataServices.Implementation;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Validation;
|
|
|
|
namespace SeaHavenIndustries.Tests;
|
|
|
|
public class WorkOrderBoardCreateServiceTests
|
|
{
|
|
private static (ApplicationDbContext Context, WorkOrderBoardCreateService Service) CreateSut()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
var context = new ApplicationDbContext(options);
|
|
var boardData = new WorkOrderBoardDataService(context);
|
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
|
var boardService = new WorkOrderBoardService(boardData);
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
var validator = new WorkOrderBoardCreateValidation();
|
|
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator);
|
|
return (context, service);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_Minimal_ReturnsIncomplete()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
|
Assert.Equal("BK5", result.SiteCode);
|
|
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
|
|
Assert.False(string.IsNullOrWhiteSpace(result.WoNumber));
|
|
Assert.Equal(11, result.WoNumber!.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WithManualWoNumber_NormalizesTo11Digits()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WoNumber = "12345",
|
|
WorkOrderType = WorkOrderType.PO,
|
|
SiteCode = "DAL"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal("00000012345", result.WoNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_DuplicateWoNumber_Throws()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
context.workOrders.Add(new WorkOrder { InternalWONumber = "00000099999", WorkerOrderNumber = "00000099999" });
|
|
await context.SaveChangesAsync();
|
|
|
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WoNumber = "99999",
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5"
|
|
}, "actor-1"));
|
|
|
|
Assert.Equal("DuplicateWoNumber", ex.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_AutoSchedulesWhenDateAndAssignee()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
AssignTo = "dispatcher-1",
|
|
ScheduledDate = new DateTime(2026, 6, 25)
|
|
}, "actor-1");
|
|
|
|
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
|
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WeekOnlyScheduling_SetsTargetWeek()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
ScheduleWeekOnly = true,
|
|
TargetWeek = new DateOnly(2026, 6, 22)
|
|
}, "actor-1");
|
|
|
|
Assert.True(result.ScheduleWeekOnly);
|
|
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WeekOnlyWithoutTargetWeek_ThrowsValidation()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
|
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
ScheduleWeekOnly = true
|
|
}, "actor-1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WithVendor_CreatesPrimaryDispatch()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
|
|
await context.SaveChangesAsync();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
VendorId = 5,
|
|
ApptDate = new DateTime(2026, 6, 26)
|
|
}, "actor-1");
|
|
|
|
Assert.Equal(5, result.VendorId);
|
|
Assert.NotNull(result.PrimaryDispatchId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_CreatesFieldLocksForPopulatedFields()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
Description = "Test WO"
|
|
}, "actor-1");
|
|
|
|
var locks = await context.WorkOrderFieldLocks.ToListAsync();
|
|
Assert.Contains(locks, l => l.FieldName == "SiteCode");
|
|
Assert.Contains(locks, l => l.FieldName == "WorkOrderType");
|
|
Assert.Contains(locks, l => l.FieldName == "Description");
|
|
Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WritesCreateAuditEvent()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5"
|
|
}, "actor-1");
|
|
|
|
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
|
Assert.Contains(logs, l => l.Action == "Create");
|
|
Assert.Contains(logs, l => l.Action == "FieldChanged" && l.FieldName == "SiteCode");
|
|
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
|
|
Assert.True(result.Id > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_StructuredServiceNotes_PersistsAndReturnsPm()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
PrimaryService = "HVAC PM",
|
|
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
|
|
ServiceNotes = "Unit on roof"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal("HVAC PM", result.Pm);
|
|
Assert.Equal("Unit on roof", result.ServiceNotes);
|
|
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
|
|
|
|
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
|
Assert.Equal("HVAC PM", wo.Trade);
|
|
Assert.Equal("Unit on roof", wo.ServiceNotes);
|
|
Assert.Contains("Filter change", wo.ExtraServices);
|
|
Assert.Contains("Coil clean", wo.ExtraServices);
|
|
Assert.Equal(1, System.Text.Json.JsonSerializer.Deserialize<List<string>>(wo.ExtraServices!)!
|
|
.Count(s => s == "Filter change"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_PrimaryServiceTakesPrecedenceOverTrade()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.Reactive,
|
|
SiteCode = "CHI",
|
|
Trade = "Legacy Trade",
|
|
PrimaryService = "Plumbing"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal("Plumbing", result.Pm);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_ExtraServicesWithoutPrimary_ThrowsValidation()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
|
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
ExtraServices = new List<string> { "Filter change" }
|
|
}, "actor-1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_PocFreeText_WithoutContact_AppearsOnBoardRow()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.Reactive,
|
|
SiteCode = "BK5",
|
|
PocName = "Jane Site Lead",
|
|
PocPhone = "+1 555-0100",
|
|
PocNotes = "Call 30 min before"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal("Jane Site Lead", result.PocName);
|
|
Assert.Equal("+1 555-0100", result.PocPhone);
|
|
Assert.Equal("Call 30 min before", result.PocNotes);
|
|
|
|
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
|
Assert.Equal("Jane Site Lead", wo.PocName);
|
|
Assert.Empty(context.WorkOrderContacts);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WithoutVendor_PersistsTechPhoneAndVendorNotesOnWorkOrder()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
TechPhone = "+1 555-0199",
|
|
VendorNotes = "Gate code 4421"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal("+1 555-0199", result.TechPhone);
|
|
|
|
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
|
Assert.Equal("+1 555-0199", wo.TechPhone);
|
|
Assert.Equal("Gate code 4421", wo.VendorNotes);
|
|
Assert.Null(wo.PrimaryDispatchId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WithVendor_PersistsTechPhoneAndVendorNotesOnDispatch()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
context.Vendors.Add(new Vendor { Id = 55, CompanyName = "Acme", Phone = "+1 555-0000" });
|
|
await context.SaveChangesAsync();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
VendorId = 55,
|
|
ApptDate = new DateTime(2026, 7, 18),
|
|
TechPhone = "+1 555-0199",
|
|
VendorNotes = "Park behind dock"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal("+1 555-0199", result.TechPhone);
|
|
Assert.Equal(55, result.VendorId);
|
|
|
|
var wo = await context.workOrders.Include(w => w.PrimaryDispatch).SingleAsync(w => w.Id == result.Id);
|
|
Assert.Null(wo.TechPhone);
|
|
Assert.Null(wo.VendorNotes);
|
|
Assert.NotNull(wo.PrimaryDispatch);
|
|
Assert.Equal("+1 555-0199", wo.PrimaryDispatch!.TechPhone);
|
|
Assert.Equal("Park behind dock", wo.PrimaryDispatch.VendorNotes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_LegacyTradeAndDescription_StillWorks()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
Trade = "HVAC",
|
|
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
|
|
}, "actor-1");
|
|
|
|
Assert.Equal("HVAC", result.Pm);
|
|
Assert.Null(result.PocName);
|
|
|
|
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
|
Assert.Equal("HVAC", wo.Trade);
|
|
Assert.Contains("Vendor notes:", wo.Description);
|
|
Assert.Null(wo.PocName);
|
|
Assert.Null(wo.ExtraServices);
|
|
Assert.Null(wo.ServiceNotes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_UnknownVendorId_Throws()
|
|
{
|
|
var (_, service) = CreateSut();
|
|
|
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
{
|
|
WorkOrderType = WorkOrderType.PM,
|
|
SiteCode = "BK5",
|
|
VendorId = 999
|
|
}, "actor-1"));
|
|
|
|
Assert.Equal("VendorNotFound", ex.Code);
|
|
}
|
|
}
|