shoc-backend/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
Alexandre Brandizzi cbecc7b4ea fix(workorders): persist manual POC override with audit and site-follow (SH-379)
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.

- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
  + WorkOrderPocDataService: persists the override, stages FieldChanged audit
  entries (which also write field locks so sync never overwrites a manual POC),
  and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
  stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
  stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
  fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
  WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
2026-09-18 14:30:54 -03:00

833 lines
30 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.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);
var mutationData = new WorkOrderBoardMutationDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
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, new WorkOrderPocDataService(context));
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);
}
[Theory]
[InlineData("SH00001", "SH00001")]
[InlineData("sh00042", "SH00042")]
public async Task Create_WithManualShPlaceholderWoNumber_KeepsShPrefix(string requested, string expected)
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WoNumber = requested,
WorkOrderType = WorkOrderType.PM,
SiteCode = "DAL",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(expected, 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_AutoSchedulesWhenDateWithoutAssignee()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 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_PendingWithDate_PromotesToScheduled()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = new DateTime(2026, 6, 25)
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[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);
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
}
[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();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
}
[Fact]
public async Task Create_WithScheduledLifecycleWeekOnly_Throws()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 7, 15),
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 7, 13),
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
}
[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);
}
}