shoc-backend/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs

759 lines
27 KiB
C#
Raw Normal View History

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.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
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);
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context);
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
var mutationData = new WorkOrderBoardMutationDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
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
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, resolver);
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",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
Assert.Equal("BK5", result.SiteCode);
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
Assert.Equal("SH00001", result.WoNumber);
}
[Fact]
public async Task Create_BlankWoNumber_PreservesExistingNumericPlaceholderAndGeneratesShNumber()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
InternalWONumber = "00000000123",
WorkerOrderNumber = "00000000123"
});
await context.SaveChangesAsync();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("SH00002", result.WoNumber);
Assert.True(await context.workOrders.AnyAsync(w => w.InternalWONumber == "00000000123"));
}
[Fact]
public async Task Create_WithManualWoNumber_NormalizesTo11Digits()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WoNumber = "12345",
WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
AssignTo = "dispatcher-1",
ScheduledDate = new DateTime(2026, 6, 25)
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22)
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
ScheduleWeekOnly = true
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
VendorId = 5,
ApptDate = new DateTime(2026, 6, 26)
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
Description = "Test WO"
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
PrimaryService = "HVAC PM",
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
ServiceNotes = "Unit on roof"
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
Trade = "Legacy Trade",
PrimaryService = "Plumbing"
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
ExtraServices = new List<string> { "Filter change" }
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
PocName = "Jane Site Lead",
PocPhone = "+1 555-0100",
PocNotes = "Call 30 min before"
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
TechPhone = "+1 555-0199",
VendorNotes = "Gate code 4421"
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
VendorId = 55,
ApptDate = new DateTime(2026, 7, 18),
TechPhone = "+1 555-0199",
VendorNotes = "Park behind dock"
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
Trade = "HVAC",
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
}, WorkOrderAccountTestHelpers.AccountUser(), "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",
LocationId = 1,
VendorId = 999
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("VendorNotFound", ex.Code);
}
[Fact]
public async Task Create_ScheduledAfterCutoff_SetsIsAddOnTrue_KeepsRealType()
{
// UtcNow (test run) is after cutoff for a 2026-06 schedule week.
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
ScheduledDate = new DateTime(2026, 6, 24),
IsAddOn = false
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.True(result.IsAddOn);
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.True(wo.IsAddOn);
Assert.Equal(WorkOrderType.PM, wo.WorkOrderType);
}
[Fact]
public async Task Create_ScheduledBeforeCutoff_SetsIsAddOnFalse_IgnoresClientTrue()
{
// Far-future schedule → cutoff after UtcNow → not an add-on.
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5",
LocationId = 1,
ScheduledDate = new DateTime(2099, 3, 10),
IsAddOn = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.False(result.IsAddOn);
Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.False(wo.IsAddOn);
}
[Fact]
public async Task Create_NoSchedule_ManualIsAddOnTrue()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
IsAddOn = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.True(result.IsAddOn);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.True(wo.IsAddOn);
}
[Fact]
public async Task Create_AvetaRequiredTrue_PersistsAndReturns()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
AvetaRequired = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.True(result.AvetaRequired);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.True(wo.AvetaRequired);
Assert.Contains(
await context.WorkOrderAuditLogs.ToListAsync(),
l => l.FieldName == "AvetaRequired" && l.Action == "FieldChanged");
}
[Fact]
public async Task Create_AvetaRequiredOmitted_DefaultsFalse()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.False(result.AvetaRequired);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.False(wo.AvetaRequired);
Assert.DoesNotContain(
await context.WorkOrderAuditLogs.ToListAsync(),
l => l.FieldName == "AvetaRequired");
}
[Fact]
public async Task Create_WorkOrderTypeAddOn_ThrowsValidation()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.AddOn,
SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
}
[Fact]
public async Task Create_AdditionalContacts_PersistsAndReturnsOnBoardRow()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5",
LocationId = 1,
PocName = "Primary Lead",
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
new("Backup Lead", "+1 555-0101", "After hours"),
new("Security Desk", "+1 555-0102", null)
}
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("Primary Lead", result.PocName);
Assert.NotNull(result.AdditionalContacts);
Assert.Equal(2, result.AdditionalContacts!.Count);
Assert.Equal("Backup Lead", result.AdditionalContacts[0].Name);
Assert.Equal("+1 555-0101", result.AdditionalContacts[0].Phone);
Assert.Equal("After hours", result.AdditionalContacts[0].Notes);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.NotNull(wo.AdditionalContacts);
Assert.Contains("Backup Lead", wo.AdditionalContacts);
Assert.Contains("Security Desk", wo.AdditionalContacts);
}
[Fact]
public async Task Create_AdditionalContacts_EmptyList_SucceedsWithoutContacts()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto>()
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Null(result.AdditionalContacts);
}
[Fact]
public async Task Create_AdditionalContacts_DropsBlankRows_PreservesOrder()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
new("", "", null),
new("Backup Lead", "+1 555-0101", "After hours"),
new(" ", " ", null),
new("Security Desk", "+1 555-0102", null)
}
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.NotNull(result.AdditionalContacts);
Assert.Equal(2, result.AdditionalContacts!.Count);
Assert.Equal("Backup Lead", result.AdditionalContacts[0].Name);
Assert.Equal("Security Desk", result.AdditionalContacts[1].Name);
}
[Fact]
public async Task Create_AdditionalContacts_NameOnly_Throws()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
new("Backup Lead", "", null)
}
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Contains("name and phone", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Create_AdditionalContacts_PhoneOnly_Throws()
{
var (_, service) = CreateSut();
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
new("", "+1 555-0101", null)
}
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
}
[Fact]
public async Task Create_AdditionalContacts_PersistsAndReturnsOnDetail()
{
var (context, service) = CreateSut();
var created = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{
new("Backup Lead", "+1 555-0101", "After hours"),
new("Security Desk", "+1 555-0102", null)
}
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var detail = new WorkOrderDetailService(
new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver),
new WorkOrderDetailDataService(context),
new CompletionDocTemplateDataService(context),
new UserDataService(context),
resolver);
var result = await detail.GetDetailAsync(created.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.NotNull(result);
Assert.NotNull(result!.Info.AdditionalContacts);
Assert.Equal(2, result.Info.AdditionalContacts!.Count);
Assert.Equal("Backup Lead", result.Info.AdditionalContacts[0].Name);
Assert.Equal("+1 555-0101", result.Info.AdditionalContacts[0].Phone);
Assert.Equal("Security Desk", result.Info.AdditionalContacts[1].Name);
}
[Fact]
public async Task Create_WithPendingLifecycle_PersistsPending()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Pending,
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
}
[Fact]
public async Task Create_WithScheduledLifecycleAndDate_PersistsWithoutAssignee()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 7, 15),
ScheduleWeekOnly = false,
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Equal(new DateTime(2026, 7, 15), result.ScheduledDate?.Date);
}
[Fact]
public async Task Create_WithScheduledLifecycleWithoutDate_Throws()
{
var (_, service) = CreateSut();
await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
}
[Fact]
public async Task Create_EmergencyWithSeverity_Persists()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Emergency,
SiteCode = "BK5",
LocationId = 1,
Severity = 2
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(2, result.Severity);
Assert.Equal("2", (await context.workOrders.SingleAsync()).Severity);
}
[Fact]
public async Task Create_PmWithSeverity_Ignores()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
Severity = 2
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Null(result.Severity);
Assert.Null((await context.workOrders.SingleAsync()).Severity);
}
[Fact]
public async Task Create_SeverityOutOfRange_ThrowsInvalidValue()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Emergency,
SiteCode = "BK5",
LocationId = 1,
Severity = 6
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
}