shoc-backend/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs

2333 lines
84 KiB
C#
Raw Normal View History

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardUpdateServiceTests
{
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
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 fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
return (context, service);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
private static string ToVersion(Dispatch dispatch)
=> Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 });
private static Task<int[]> DispatchIdsForWorkOrderAsync(ApplicationDbContext context, int workOrderId)
=> context.Dispatches
.Where(dispatch =>
dispatch.WorkOrderId == workOrderId
|| context.DispatchWorkOrders.Any(link =>
link.DispatchId == dispatch.Id && link.WorkOrderId == workOrderId))
.Select(dispatch => dispatch.Id)
.OrderBy(id => id)
.ToArrayAsync();
private static Task<bool> UpliftAssociatedAsync(
ApplicationDbContext context,
int workOrderId,
int dispatchId)
=> context.DispatchUpliftRequests.AnyAsync(uplift =>
uplift.DispatchId == dispatchId
&& (context.Dispatches.Any(dispatch =>
dispatch.Id == dispatchId && dispatch.WorkOrderId == workOrderId)
|| context.DispatchWorkOrders.Any(link =>
link.DispatchId == dispatchId && link.WorkOrderId == workOrderId)));
[Fact]
public async Task PatchField_AutoSchedulesIncompleteToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Equal(3, audits.Count);
Assert.Contains(audits, a => a.FieldName == "IsAddOn");
Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
}
[Fact]
public async Task PatchField_PendingWithDate_PromotesToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_ClearsScheduledDate_DemotesToPending()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
Assert.Null(result.ScheduledDate);
var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
Assert.Contains(audits, a => a.FieldName == "LifecycleStatus" && a.NewValue == LifecycleStatus.Pending.ToString());
}
[Fact]
public async Task PatchField_ScheduleWeekOnly_DemotesScheduledToPending()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 25),
ScheduleWeekOnly = false,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduleWeekOnly,
Value = "true",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
Assert.True(result.ScheduleWeekOnly);
var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
}
[Fact]
public async Task PatchField_ScheduleWeekOnlyFalse_PromotesPendingToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = new DateTime(2026, 12, 15),
ScheduleWeekOnly = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduleWeekOnly,
Value = "false",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.False(result.ScheduleWeekOnly);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithoutDate_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
Assert.Equal(LifecycleStatus.Incomplete, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithWeekOnly_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithConcreteDate_Persists()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = false,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusPendingWithConcreteDate_PromotesToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = false,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Pending",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_InProgress_ClearDate_DoesNotChangeStatus()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.InProgress,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.InProgress, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_EnRoute_ClearDate_DoesNotChangeStatus()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.EnRoute,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.EnRoute, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_IncrementsRescheduleCount()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 20),
RescheduleCount = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(2, result.RescheduleCount);
}
[Fact]
public async Task PatchField_BlocksStatusChangeWhenPastDue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code);
Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PatchField_FutureSchedule_ClearsPastDueAndAllowsStatusChange()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LocationId = 10,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Locations.Add(new Locations { Id = 10, Name = "Test Site" });
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var futureSchedule = DateTime.UtcNow.Date.AddDays(7).ToString("yyyy-MM-dd");
var row = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = futureSchedule,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.False(row.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
Assert.False(afterStatus.IsPastDue);
}
[Fact]
public async Task PatchField_ProvisionalWoNumber_BlocksCompletionUntilReplaced()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LocationId = 10,
InternalWONumber = "SH00001",
WorkerOrderNumber = "SH00001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.Date.AddDays(1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Locations.Add(new Locations { Id = 10, Name = "Test Site" });
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var blocked = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ProvisionalWoNumber", blocked.Code);
var renumbered = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "12345",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("00000012345", renumbered.WoNumber);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var completed = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
}
[Fact]
public async Task PatchField_DueDateAlone_DoesNotClearPastDue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var futureDue = DateTime.UtcNow.Date.AddDays(10).ToString("yyyy-MM-dd");
var afterDue = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DueDate,
Value = futureDue,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.True(afterDue.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code);
Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PatchField_DueDate_DoesNotChangeScheduledDate()
{
var (context, service) = CreateSut();
var scheduled = new DateTime(2026, 6, 20);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = new DateTime(2026, 6, 18),
ScheduledDate = scheduled,
RescheduleCount = 2,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DueDate,
Value = "2026-07-01",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 7, 1), result.DueDate!.Value.Date);
Assert.Equal(scheduled, result.ScheduledDate);
Assert.Equal(2, result.RescheduleCount);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(scheduled, persisted.ScheduledDate);
Assert.Equal(2, persisted.RescheduleCount);
}
[Fact]
public async Task PatchField_ScheduledDate_DoesNotChangeDueDate()
{
var (context, service) = CreateSut();
var due = new DateTime(2026, 6, 18);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = due,
ScheduledDate = new DateTime(2026, 6, 20),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate!.Value.Date);
Assert.Equal(due, result.DueDate);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(due, persisted.DueDate);
}
[Fact]
public async Task PatchField_AcceptsOverdueAsWorkOrderType()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Reactive,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Equal(WorkOrderType.Overdue, (await context.workOrders.SingleAsync(w => w.Id == 1)).WorkOrderType);
}
[Fact]
public async Task PatchField_BlocksEditWhenCanceled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Canceled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
[Fact]
public async Task PatchField_VendorCreatesPrimaryDispatch()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co" });
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.NotNull(result.PrimaryDispatchId);
Assert.Equal(5, result.VendorId);
Assert.Equal(1, await context.Dispatches.CountAsync());
}
[Theory]
[InlineData("Cancelled")]
[InlineData("Canceled")]
[InlineData("Refused")]
[InlineData(" refused ")]
public async Task PatchField_VendorId_CreatesNewDispatchWhenPrimaryIsInactive(string inactiveStatus)
{
var (context, service) = CreateSut();
context.Vendors.AddRange(
new Vendor { Id = 1, CompanyName = "Old Co" },
new Vendor { Id = 5, CompanyName = "New Co" });
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = inactiveStatus,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.Equal("New Co", result.VendorName);
Assert.Equal("Pending", result.PrimaryDispatchStatus);
Assert.NotEqual(10, result.PrimaryDispatchId);
Assert.Equal(2, await context.Dispatches.CountAsync());
var leftover = await context.Dispatches.SingleAsync(d => d.Id == 10);
Assert.Equal(1, leftover.VendorId);
Assert.Equal(inactiveStatus, leftover.Status);
var vendorAudit = Assert.Single(
await context.WorkOrderAuditLogs
.Where(a => a.Action == AuditActionType.FieldChanged.ToString() && a.FieldName == "VendorId")
.ToListAsync());
Assert.Equal("1", vendorAudit.OldValue);
Assert.Equal("5", vendorAudit.NewValue);
Assert.Equal(result.PrimaryDispatchId, vendorAudit.DispatchId);
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "VendorId"));
}
[Theory]
[InlineData("Sent")]
[InlineData("Verified")]
[InlineData("Completed")]
public async Task PatchField_VendorId_UpdatesExistingLivePrimaryDispatch(string liveStatus)
{
var (context, service) = CreateSut();
context.Vendors.AddRange(
new Vendor { Id = 1, CompanyName = "Old Co" },
new Vendor { Id = 5, CompanyName = "New Co" });
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = liveStatus,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.Equal(10, result.PrimaryDispatchId);
Assert.Equal(liveStatus, result.PrimaryDispatchStatus);
Assert.Equal(1, await context.Dispatches.CountAsync());
var updated = await context.Dispatches.SingleAsync();
Assert.Equal(5, updated.VendorId);
Assert.Equal(liveStatus, updated.Status);
}
[Fact]
public async Task PatchField_VendorId_SucceedsWhenClientPrimaryDispatchIdDoesNotBelong()
{
var (context, service) = CreateSut();
context.Vendors.AddRange(
new Vendor { Id = 1, CompanyName = "Old Co" },
new Vendor { Id = 5, CompanyName = "New Co" });
var ownDispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var foreignDispatch = new Dispatch
{
Id = 99,
WorkOrderId = 2,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 3 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
var otherWo = new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 99,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 4 }
};
context.Dispatches.AddRange(ownDispatch, foreignDispatch);
context.workOrders.AddRange(wo, otherWo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(ownDispatch),
PrimaryDispatchId = 99
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.Equal(10, result.PrimaryDispatchId);
var leftoverForeign = await context.Dispatches.SingleAsync(d => d.Id == 99);
Assert.Equal(1, leftoverForeign.VendorId);
}
[Fact]
public async Task PatchField_VendorId_TreatsZeroPrimaryDispatchIdAsOmitted()
{
var (context, service) = CreateSut();
context.Vendors.AddRange(
new Vendor { Id = 1, CompanyName = "Old Co" },
new Vendor { Id = 5, CompanyName = "New Co" });
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 0
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.Equal(10, result.PrimaryDispatchId);
Assert.Equal(1, await context.Dispatches.CountAsync());
}
[Fact]
public async Task PatchField_VendorId_SharedDispatch_CreatesWoOwnedDispatch()
{
var (context, service) = CreateSut();
context.Vendors.AddRange(
new Vendor { Id = 1, CompanyName = "Old Co" },
new Vendor { Id = 5, CompanyName = "New Co" });
var shared = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var primaryWo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
var secondaryWo = new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 }
};
context.Dispatches.Add(shared);
context.workOrders.AddRange(primaryWo, secondaryWo);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 },
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 });
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 200,
DispatchId = 10,
RequestedNTE = 900m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(2, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(secondaryWo),
DispatchVersion = ToVersion(shared),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.NotEqual(10, result.PrimaryDispatchId);
Assert.Equal("Pending", result.PrimaryDispatchStatus);
var original = await context.Dispatches.SingleAsync(d => d.Id == 10);
Assert.Equal(1, original.VendorId);
Assert.Equal("Sent", original.Status);
Assert.Equal(1, original.WorkOrderId);
var created = await context.Dispatches.SingleAsync(d => d.Id == result.PrimaryDispatchId);
Assert.Equal(2, created.WorkOrderId);
Assert.Equal(5, created.VendorId);
var primaryUnchanged = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(10, primaryUnchanged.PrimaryDispatchId);
Assert.False(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 2));
Assert.True(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 1));
var secondaryDispatchIds = await DispatchIdsForWorkOrderAsync(context, 2);
var primaryDispatchIds = await DispatchIdsForWorkOrderAsync(context, 1);
Assert.Equal(new int[] { result.PrimaryDispatchId!.Value }, secondaryDispatchIds);
Assert.Equal(new int[] { 10 }, primaryDispatchIds);
Assert.False(await UpliftAssociatedAsync(context, 2, 10));
Assert.True(await UpliftAssociatedAsync(context, 1, 10));
}
[Fact]
public async Task PatchField_VendorId_SharedDispatch_OwnerChange_RehomesDirectOwnership()
{
var (context, service) = CreateSut();
context.Vendors.AddRange(
new Vendor { Id = 1, CompanyName = "Old Co" },
new Vendor { Id = 5, CompanyName = "New Co" });
var shared = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var primaryWo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
var secondaryWo = new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 }
};
context.Dispatches.Add(shared);
context.workOrders.AddRange(primaryWo, secondaryWo);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 },
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 });
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "5",
WorkOrderVersion = ToVersion(primaryWo),
DispatchVersion = ToVersion(shared),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.NotEqual(10, result.PrimaryDispatchId);
var original = await context.Dispatches.SingleAsync(d => d.Id == 10);
Assert.Equal(1, original.VendorId);
Assert.Equal(2, original.WorkOrderId);
Assert.False(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 1));
Assert.True(await context.DispatchWorkOrders.AnyAsync(link =>
link.DispatchId == 10 && link.WorkOrderId == 2));
var secondaryUnchanged = await context.workOrders.SingleAsync(w => w.Id == 2);
Assert.Equal(10, secondaryUnchanged.PrimaryDispatchId);
var ownerDispatchIds = await DispatchIdsForWorkOrderAsync(context, 1);
var remainingDispatchIds = await DispatchIdsForWorkOrderAsync(context, 2);
Assert.Equal(new int[] { result.PrimaryDispatchId!.Value }, ownerDispatchIds);
Assert.Equal(new int[] { 10 }, remainingDispatchIds);
}
[Fact]
public async Task PatchField_VendorId_SharedDispatch_SameVendor_IsIdempotent()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Old Co" });
var appointment = new DateTime(2026, 6, 26);
var shared = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
ScheduledDate = appointment,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var primaryWo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
var secondaryWo = new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 }
};
context.Dispatches.Add(shared);
context.workOrders.AddRange(primaryWo, secondaryWo);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 },
new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 });
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(2, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "1",
WorkOrderVersion = ToVersion(secondaryWo),
DispatchVersion = ToVersion(shared),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Equal(1, result.VendorId);
Assert.Equal(10, result.PrimaryDispatchId);
Assert.Equal("Sent", result.PrimaryDispatchStatus);
Assert.Equal(1, await context.Dispatches.CountAsync());
var unchanged = await context.Dispatches.SingleAsync(d => d.Id == 10);
Assert.Equal(appointment, unchanged.ScheduledDate);
Assert.Equal("Sent", unchanged.Status);
Assert.Equal(1, unchanged.VendorId);
Assert.Equal(2, await context.DispatchWorkOrders.CountAsync());
Assert.Empty(await context.WorkOrderAuditLogs
.Where(a => a.FieldName == "VendorId")
.ToListAsync());
}
[Fact]
public async Task PatchField_ApptDate_ClientIdNotBelonging_ThrowsInvalidDispatch()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
var ownDispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var foreignDispatch = new Dispatch
{
Id = 99,
WorkOrderId = 2,
VendorId = 1,
Status = "Sent",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 3 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
var otherWo = new WorkOrder
{
Id = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 99,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 4 }
};
context.Dispatches.AddRange(ownDispatch, foreignDispatch);
context.workOrders.AddRange(wo, otherWo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptDate,
Value = "2026-06-26",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(ownDispatch),
PrimaryDispatchId = 99
}, "actor-1"));
Assert.Equal("InvalidDispatch", ex.Code);
}
[Fact]
public async Task PatchField_CreatesFieldLockAndAuditPerField()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
SiteCode = "OLD",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.Action == AuditActionType.FieldChanged.ToString()).ToListAsync());
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
}
[Fact]
public async Task PatchField_NormalizesWoNumber()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000009999",
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "12345",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("00000012345", result.WoNumber);
}
[Fact]
public async Task PatchField_ShPlaceholderReplacesShPlaceholder()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "SH00001",
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "SH00007",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("SH00007", result.WoNumber);
}
[Fact]
public async Task PatchField_RejectsReplacingRealWoNumberWithShPlaceholder()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000012345",
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "SH00001",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("WoNumberDowngrade", ex.Code);
var stored = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal("00000012345", stored.InternalWONumber);
}
[Fact]
public async Task PatchField_RejectsDuplicateWoNumber()
{
var (context, service) = CreateSut();
context.workOrders.AddRange(
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
await context.SaveChangesAsync();
var wo = await context.workOrders.FindAsync(1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "22222",
WorkOrderVersion = ToVersion(wo!)
}, "actor-1"));
Assert.Equal("DuplicateWoNumber", ex.Code);
}
[Fact]
public async Task PatchField_UpdatesPmTrade()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "HVAC",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Pm,
Value = "Plumbing",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("Plumbing", result.Pm);
Assert.Equal("Plumbing", (await context.workOrders.FindAsync(1))!.Trade);
}
[Fact]
public async Task PatchField_UpdatesServiceNotesAndReturnsOnBoardRow()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "Backflow Preventers",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ServiceNotes,
Value = "service notes",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("service notes", result.ServiceNotes);
Assert.Equal("service notes", (await context.workOrders.FindAsync(1))!.ServiceNotes);
}
[Fact]
public async Task PatchField_UpdatesExtraServices_WhenPrimaryExists()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "Backflow Preventers",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ExtraServices,
Value = "[\"Filter change\",\"Coil clean\"]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
Assert.Contains("Filter change", (await context.workOrders.FindAsync(1))!.ExtraServices);
}
[Fact]
public async Task PatchField_ExtraServicesWithoutPrimary_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ExtraServices,
Value = "[\"Filter change\"]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_UpdatesAdditionalContacts()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.NotNull(result.AdditionalContacts);
Assert.Single(result.AdditionalContacts!);
Assert.Equal("Jane Backup", result.AdditionalContacts![0].Name);
Assert.Contains("Jane Backup", (await context.workOrders.FindAsync(1))!.AdditionalContacts);
}
[Fact]
public async Task PatchField_InvalidAdditionalContactsJson_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "not-json",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_AdditionalContacts_NameOnly_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "[{\"name\":\"Jane Backup\",\"phone\":\"\"}]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_AdditionalContacts_PhoneOnly_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "[{\"name\":\"\",\"phone\":\"+1 555-0101\"}]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_AdditionalContacts_DropsBlankRows_PreservesOrder()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "[{\"name\":\"\",\"phone\":\"\"},{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(2, result.AdditionalContacts!.Count);
Assert.Equal("Jane Backup", result.AdditionalContacts[0].Name);
Assert.Equal("Security Desk", result.AdditionalContacts[1].Name);
}
[Fact]
public async Task PatchField_AdditionalContacts_EmptyArray_Clears()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
AdditionalContacts = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\"}]",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "[]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(result.AdditionalContacts);
Assert.Null((await context.workOrders.FindAsync(1))!.AdditionalContacts);
}
[Fact]
public async Task PatchField_SetsFlagColor_CanonicalUppercase()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#ef4444",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("#EF4444", result.FlagColor);
Assert.Equal("#EF4444", (await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_ClearsFlagColor_WithNullOrEmpty()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
FlagColor = "#EF4444",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(cleared.FlagColor);
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_ClearsFlagColor_WithNullValue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
FlagColor = "#3B82F6",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(cleared.FlagColor);
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_FlagColorOutsidePalette_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#000000",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_FlagColorMalformed_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "not-a-color",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_FlagColor_DoesNotRequireDispatchVersion()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Dispatches.Add(new Dispatch
{
Id = 10,
WorkOrderId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
});
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#8B5CF6",
WorkOrderVersion = ToVersion(wo)
// no DispatchVersion
}, "actor-1");
Assert.Equal("#8B5CF6", result.FlagColor);
}
merge: integrate origin/dev into PR #22 flag-color base Brings in dev's Phase 5 (PR #17) + vendor PRs (#25/#28/#29) atop the Phase 6/7 + flagColor base (PR #22). Preserves dev Phase 1-5 behavior and PR #22 Phase 6/7 + flagColor behavior. Conflict resolutions (16 files): - Migrations Phase4_SearchIndexes/.Designer + Phase5_DomainEvents/.Designer: take dev (Phase4 incl. SQL Server SiteCode/InternalWONumber index-compat shrink fix; Phase5 identical). ModelSnapshot union: Vendor CompanyId index + Phase7 ServiceNotes/ExternalWorkOrderId index. - ApplicationDbContext: keep dev SiteCode/InternalWONumber MaxLength (Phase1-5 + unguarded model test) + HEAD CompletionDocTemplate/ExternalWorkOrderId. - WorkOrderAuditService: unify on dev async staging API; convert Phase6 CompletionService 2 call sites to await StageFieldChangedAsync (drops HEAD sync duplicate; only callers, no test refs). - Hosted services: take HEAD (retry-on-failure, coherent with Phase7 WorkOrderJobRunStateAccessor/OpsHealth). Program.cs keeps dev vendor DI (ClamAV/VendorDocumentScanWorker/ArgumentExceptionFilter) + HEAD Phase7. - WorkOrderController: keep HEAD Phase6/7 service params + dev doc comment. - VendorController/WorkOrderBoardCreateService/QueryFilters/appsettings: union / dev-correct. - WorkOrderBoardUpdateServiceTests: union of HEAD (Phase6/7+flagColor) and dev (Phase1-5) test methods. Verified WorkOrderType.Other (enum 99) is a legit category, not an overdue sentinel; overdue uses dedicated OperationalFlags.PastDue + IsPastDue, and 'Overdue' is rejected as a WorkOrderType (no PR #23 import needed). Removed dev duplicate Api.Options.WorkOrderJobRunState (HEAD defines it in Services.Implementation alongside the Accessor; Services cannot reference Api).
2026-07-24 14:20:16 -03:00
[Fact]
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
SiteCode = "OLD",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var updated = await context.workOrders.FindAsync(1);
var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK6",
WorkOrderVersion = ToVersion(updated!)
}, "actor-1");
Assert.Equal("BK6", secondResult.SiteCode);
Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync());
Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync());
}
[Fact]
public async Task PatchField_NotFound_ThrowsNotFoundCode()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
}, "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = new DateTime(2026, 6, 25),
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AssignTo,
Value = "user-a",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
Assert.Equal("user-a", result.DispatcherId);
}
[Fact]
public async Task PatchField_ApptTime_UpdatesScheduledStart()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "09:00-10:00",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
Assert.Contains("09:00", result.ApptTime);
var reloaded = await context.workOrders.FindAsync(1);
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded!.ScheduledStart);
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
}
[Fact]
public async Task PatchField_ApptTime_EmptyClearsStartAndEnd_KeepsAppointmentDate()
{
var (context, service) = CreateSut();
var appointmentDate = new DateTime(2026, 6, 25);
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
ScheduledDate = appointmentDate,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
ScheduledDate = appointmentDate,
ScheduledStart = new DateTime(2026, 6, 25, 9, 0, 0),
ScheduledEnd = new DateTime(2026, 6, 25, 11, 0, 0),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
Assert.True(string.IsNullOrEmpty(result.ApptTime));
Assert.Equal(appointmentDate, result.ScheduledDate);
var reloaded = await context.workOrders.FindAsync(1);
Assert.Null(reloaded!.ScheduledStart);
Assert.Null(reloaded.ScheduledEnd);
Assert.Equal(appointmentDate, reloaded.ScheduledDate);
var reloadedDispatch = await context.Dispatches.FindAsync(10);
Assert.Equal(appointmentDate, reloadedDispatch!.ScheduledDate);
}
[Fact]
public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "-30",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
var reloaded = await context.workOrders.FindAsync(1);
Assert.NotNull(reloaded!.ScheduledStart);
Assert.Null(reloaded.ScheduledEnd);
Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart);
}
[Fact]
public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "09:00-10:00",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
var lockNames = await context.WorkOrderFieldLocks
.Where(l => l.WorkOrderId == 1)
.Select(l => l.FieldName)
.ToListAsync();
Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames);
Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames);
Assert.DoesNotContain("ApptTime", lockNames);
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 locks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), locks);
var policy = new SyncFieldMergePolicy(locks);
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = reloaded,
FieldLocks = locks,
Audit = audit
};
var appliedStart = await policy.TryApplyAsync(
syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00");
var appliedEnd = await policy.TryApplyAsync(
syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00");
Assert.False(appliedStart);
Assert.False(appliedEnd);
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded.ScheduledStart);
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
}
[Fact]
public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptDate,
Value = "2026-06-26",
WorkOrderVersion = ToVersion(wo),
PrimaryDispatchId = 10
}, "actor-1"));
Assert.Equal("DispatchVersionRequired", ex.Code);
}
[Fact]
public async Task PatchField_VendorIdNotFound_Throws()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.VendorId,
Value = "999",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1"));
Assert.Equal("VendorNotFound", ex.Code);
}
[Fact]
public async Task PatchField_UpdatesCompletedDate()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.WorkOrderContacts.Add(new WorkOrderContacts { Id = 1, WorkorderId = 1, ContactId = 50 });
context.workOrderCategories.Add(new WorkOrderCategories { Id = 1, WorkorderId = 1, CategoryId = 60 });
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.CompletedDate,
Value = "2026-07-20",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 7, 20), result.CompletedDate);
Assert.Equal(new DateTime(2026, 7, 20), context.workOrders.Single().CompletedDate);
Assert.Equal(1, await context.WorkOrderContacts.CountAsync());
Assert.Equal(1, await context.workOrderCategories.CountAsync());
Assert.Equal(50, context.WorkOrderContacts.Single().ContactId);
Assert.Equal(60, context.workOrderCategories.Single().CategoryId);
}
[Fact]
public async Task PatchField_ClearsCompletedDate_WhenValueEmpty()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
CompletedDate = new DateTime(2026, 7, 1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.CompletedDate,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(result.CompletedDate);
Assert.Null(context.workOrders.Single().CompletedDate);
}
[Fact]
public async Task PatchField_ClearsIsAddOn_WhenScheduleCleared()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = new DateTime(2026, 6, 20),
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
IsAddOn = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.False(result.IsAddOn);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.False(persisted.IsAddOn);
Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
}
[Fact]
public async Task PatchField_ScheduledDate_RecalculatesIsAddOn_AfterCrossWeekMove()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = new DateTime(2026, 6, 10),
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
IsAddOn = false,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.True(result.IsAddOn);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.True(persisted.IsAddOn);
}
[Fact]
public async Task PatchField_WorkOrderType_DoesNotRecalculateIsAddOn()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
IsAddOn = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Reactive",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
Assert.True(result.IsAddOn);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Reactive, persisted.WorkOrderType);
Assert.True(persisted.IsAddOn);
Assert.Empty(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
}
[Fact]
public async Task PatchField_AvetaRequiredTrue_PersistsAndAudits()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AvetaRequired,
Value = "true",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.True(result.AvetaRequired);
Assert.True((await context.workOrders.FindAsync(1))!.AvetaRequired);
Assert.Contains(
await context.WorkOrderAuditLogs.ToListAsync(),
a => a.FieldName == "AvetaRequired" && a.Action == AuditActionType.FieldChanged.ToString());
}
[Fact]
public async Task PatchField_AvetaRequiredFalse_ClearsFlag()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
AvetaRequired = true,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AvetaRequired,
Value = "false",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.False(result.AvetaRequired);
Assert.False((await context.workOrders.FindAsync(1))!.AvetaRequired);
}
[Fact]
public async Task PatchField_AvetaRequiredInvalid_ThrowsInvalidValue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AvetaRequired,
Value = "yes",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_Severity_PersistsAndAudits()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
WorkOrderType = WorkOrderType.PM,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "2",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(2, result.Severity);
Assert.Equal("2", (await context.workOrders.FindAsync(1))!.Severity);
var audits = await context.WorkOrderAuditLogs.Where(a => a.FieldName == "Severity").ToListAsync();
Assert.Single(audits);
Assert.Equal("2", audits[0].NewValue);
}
[Fact]
public async Task PatchField_SeverityEmpty_Clears()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
WorkOrderType = WorkOrderType.Emergency,
Severity = "1",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(result.Severity);
Assert.Null((await context.workOrders.FindAsync(1))!.Severity);
}
[Theory]
[InlineData("0")]
[InlineData("6")]
[InlineData("abc")]
public async Task PatchField_SeverityInvalid_ThrowsInvalidValue(string value)
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = value,
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_WorkOrderTypeToPm_ClearsSeverity()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
WorkOrderType = WorkOrderType.Emergency,
Severity = "2",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "PM",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
Assert.Null(result.Severity);
Assert.Null((await context.workOrders.FindAsync(1))!.Severity);
Assert.Contains(await context.WorkOrderAuditLogs.ToListAsync(), a => a.FieldName == "Severity" && a.NewValue == "");
}
[Fact]
public async Task PatchField_SeverityThenWorkOrderType_MatchesFrontendSequence()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
WorkOrderType = WorkOrderType.PM,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var afterSeverity = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "2",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var updated = await context.workOrders.FindAsync(1);
var afterType = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Emergency",
WorkOrderVersion = ToVersion(updated!)
}, "actor-1");
Assert.Equal(2, afterSeverity.Severity);
Assert.Equal(WorkOrderType.Emergency, afterType.WorkOrderType);
Assert.Equal(2, afterType.Severity);
}
}