mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
996 lines
35 KiB
C#
996 lines
35 KiB
C#
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);
|
|
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 });
|
|
|
|
[Fact]
|
|
public async Task PatchField_AutoSchedulesIncompleteToScheduled()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
var wo = new WorkOrder
|
|
{
|
|
Id = 1,
|
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
|
AssignTo = "user-a",
|
|
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);
|
|
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
|
|
}
|
|
|
|
[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(-3),
|
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(5),
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PatchField_RescheduleAlone_DoesNotClearPastDueBlock()
|
|
{
|
|
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 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.True(row.IsPastDue);
|
|
Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PatchField_FutureDueDate_ClearsPastDueAndAllowsStatusChange()
|
|
{
|
|
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.False(afterDue.IsPastDue);
|
|
Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.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_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_RejectsOverdueAsWorkOrderType()
|
|
{
|
|
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 ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
|
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
|
{
|
|
Field = WorkOrderBoardFieldNames.WorkOrderType,
|
|
Value = "Overdue",
|
|
WorkOrderVersion = ToVersion(wo)
|
|
}, "actor-1"));
|
|
|
|
Assert.Equal("InvalidValue", ex.Code);
|
|
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
|
|
Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[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());
|
|
}
|
|
|
|
[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_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_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);
|
|
}
|
|
|
|
[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_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.Equal(new DateTime(2026, 6, 25), 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);
|
|
|
|
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);
|
|
}
|
|
}
|