From e5902e1df38e002f7d1f4bd260a896aa0398e685 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 2 Jul 2026 09:30:05 -0300 Subject: [PATCH] fix(work-orders): address PR review on sync, audit, lock and mapper --- Data.SeaHavenIndustries/Models/WorkerOrder.cs | 4 - .../Constants/WorkOrderFieldNames.cs | 26 +++ .../Helpers/LifecycleStatusMapper.cs | 170 +++++++-------- .../Implementation/SyncFieldMergePolicy.cs | 201 ++++++++++++++---- .../Implementation/WorkOrderAuditService.cs | 29 ++- .../WorkOrderFieldLockService.cs | 16 +- .../Interfaces/ISyncFieldMergePolicy.cs | 2 +- .../LifecycleStatusMapperTests.cs | 5 +- .../SyncFieldMergePolicyTests.cs | 83 +++++++- .../WorkOrderAuditServiceTests.cs | 5 +- .../WorkOrderFieldLockServiceTests.cs | 53 +++++ 11 files changed, 452 insertions(+), 142 deletions(-) create mode 100644 SeaHaven.Services/Constants/WorkOrderFieldNames.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index a5038d7..18e51bf 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -4,10 +4,6 @@ using Data.SeaHavenIndustries.Enums; namespace Data.SeaHavenIndustries { - /// - /// Work Order aggregate root. Domain slices (Core, Scheduling, Tracking, Analytics, Completion) - /// are persisted as flat columns on workOrders for Fase 0 backward compatibility. - /// public class WorkOrder : FullAuditEntity { // --- Core slice --- diff --git a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs new file mode 100644 index 0000000..967e102 --- /dev/null +++ b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs @@ -0,0 +1,26 @@ +namespace SeaHaven.Services.Constants +{ + public static class WorkOrderFieldNames + { + public const string LifecycleStatus = nameof(LifecycleStatus); + public const string AssignTo = nameof(AssignTo); + public const string ScheduledDate = nameof(ScheduledDate); + public const string ScheduledEnd = nameof(ScheduledEnd); + public const string TargetWeek = nameof(TargetWeek); + public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly); + public const string DueDate = nameof(DueDate); + public const string Description = nameof(Description); + public const string WorkerOrderTitle = nameof(WorkerOrderTitle); + public const string SiteCode = nameof(SiteCode); + public const string Building = nameof(Building); + public const string LocationId = nameof(LocationId); + public const string WorkOrderType = nameof(WorkOrderType); + public const string Service = nameof(Service); + public const string DocStatus = nameof(DocStatus); + public const string Trade = nameof(Trade); + public const string Problem = nameof(Problem); + public const string Priority = nameof(Priority); + public const string InternalWONumber = nameof(InternalWONumber); + public const string Status = nameof(Status); + } +} diff --git a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs index 9f18ad1..b7341d6 100644 --- a/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs +++ b/SeaHaven.Services/Helpers/LifecycleStatusMapper.cs @@ -1,86 +1,86 @@ -using Data.SeaHavenIndustries.Enums; - -namespace SeaHaven.Services.Helpers -{ - public static class LifecycleStatusMapper - { - public static LifecycleStatus? FromLegacyStatus(string? status) - { - if (string.IsNullOrWhiteSpace(status)) return null; - - return status.Trim().ToLowerInvariant() switch - { - "open" or "incomplete" => LifecycleStatus.Incomplete, - "pending" or "onhold" or "on hold" => LifecycleStatus.Pending, - "scheduled" => LifecycleStatus.Scheduled, - "en route" or "enroute" => LifecycleStatus.EnRoute, - "on site" or "onsite" => LifecycleStatus.OnSite, - "inprogress" or "in progress" => LifecycleStatus.InProgress, - "completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed, - "rescheduled" => LifecycleStatus.Rescheduled, - "cancelled" or "canceled" => LifecycleStatus.Canceled, - "pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote, - _ => LifecycleStatus.Incomplete - }; - } - - public static LifecycleStatus? ParseLifecycleStatus(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - - if (TryParseFeLabel(value, out var fromLabel)) - return fromLabel; - - if (Enum.TryParse(value, true, out var parsed)) - return parsed; - - return FromLegacyStatus(value); - } - - public static bool TryParseFeLabel(string? label, out LifecycleStatus status) - { - status = default; - if (string.IsNullOrWhiteSpace(label)) return false; - - return label.Trim().ToLowerInvariant() switch - { - "incomplete" => Set(LifecycleStatus.Incomplete, out status), - "pending" => Set(LifecycleStatus.Pending, out status), - "scheduled" => Set(LifecycleStatus.Scheduled, out status), - "en route" => Set(LifecycleStatus.EnRoute, out status), - "on site" => Set(LifecycleStatus.OnSite, out status), - "in progress" => Set(LifecycleStatus.InProgress, out status), - "completed" => Set(LifecycleStatus.Completed, out status), - "rescheduled" => Set(LifecycleStatus.Rescheduled, out status), - "canceled" => Set(LifecycleStatus.Canceled, out status), - "pending quote" => Set(LifecycleStatus.PendingQuote, out status), - _ => false - }; - } - - public static string ToFeLabel(LifecycleStatus status) => status switch - { - LifecycleStatus.Incomplete => "Incomplete", - LifecycleStatus.Pending => "Pending", - LifecycleStatus.Scheduled => "Scheduled", - LifecycleStatus.EnRoute => "En Route", - LifecycleStatus.OnSite => "On Site", - LifecycleStatus.InProgress => "In Progress", - LifecycleStatus.Completed => "Completed", - LifecycleStatus.Rescheduled => "Rescheduled", - LifecycleStatus.Canceled => "Canceled", - LifecycleStatus.PendingQuote => "Pending Quote", - _ => status.ToString() - }; - - public static bool IsTerminal(LifecycleStatus? status) - => status is LifecycleStatus.Completed or LifecycleStatus.Canceled; - - private static bool Set(LifecycleStatus value, out LifecycleStatus status) - { - status = value; - return true; - } - } -} +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class LifecycleStatusMapper + { + public static LifecycleStatus? FromLegacyStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) return null; + + return status.Trim().ToLowerInvariant() switch + { + "open" or "incomplete" => LifecycleStatus.Incomplete, + "pending" or "onhold" or "on hold" => LifecycleStatus.Pending, + "scheduled" => LifecycleStatus.Scheduled, + "en route" or "enroute" => LifecycleStatus.EnRoute, + "on site" or "onsite" => LifecycleStatus.OnSite, + "inprogress" or "in progress" => LifecycleStatus.InProgress, + "completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed, + "rescheduled" => LifecycleStatus.Rescheduled, + "cancelled" or "canceled" => LifecycleStatus.Canceled, + "pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote, + _ => null + }; + } + + public static LifecycleStatus? ParseLifecycleStatus(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + if (TryParseFeLabel(value, out var fromLabel)) + return fromLabel; + + if (Enum.TryParse(value, true, out var parsed)) + return parsed; + + return FromLegacyStatus(value); + } + + public static bool TryParseFeLabel(string? label, out LifecycleStatus status) + { + status = default; + if (string.IsNullOrWhiteSpace(label)) return false; + + return label.Trim().ToLowerInvariant() switch + { + "incomplete" => Set(LifecycleStatus.Incomplete, out status), + "pending" => Set(LifecycleStatus.Pending, out status), + "scheduled" => Set(LifecycleStatus.Scheduled, out status), + "en route" => Set(LifecycleStatus.EnRoute, out status), + "on site" => Set(LifecycleStatus.OnSite, out status), + "in progress" => Set(LifecycleStatus.InProgress, out status), + "completed" => Set(LifecycleStatus.Completed, out status), + "rescheduled" => Set(LifecycleStatus.Rescheduled, out status), + "canceled" => Set(LifecycleStatus.Canceled, out status), + "pending quote" => Set(LifecycleStatus.PendingQuote, out status), + _ => false + }; + } + + public static string ToFeLabel(LifecycleStatus status) => status switch + { + LifecycleStatus.Incomplete => "Incomplete", + LifecycleStatus.Pending => "Pending", + LifecycleStatus.Scheduled => "Scheduled", + LifecycleStatus.EnRoute => "En Route", + LifecycleStatus.OnSite => "On Site", + LifecycleStatus.InProgress => "In Progress", + LifecycleStatus.Completed => "Completed", + LifecycleStatus.Rescheduled => "Rescheduled", + LifecycleStatus.Canceled => "Canceled", + LifecycleStatus.PendingQuote => "Pending Quote", + _ => status.ToString() + }; + + public static bool IsTerminal(LifecycleStatus? status) + => status is LifecycleStatus.Completed or LifecycleStatus.Canceled; + + private static bool Set(LifecycleStatus value, out LifecycleStatus status) + { + status = value; + return true; + } + } +} \ No newline at end of file diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs index ddf05bd..eefdcbd 100644 --- a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs +++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs @@ -1,4 +1,6 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Constants; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -7,24 +9,39 @@ namespace SeaHaven.Services.Implementation { private static readonly HashSet ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase) { - "LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly", - "DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId", - "WorkOrderType", "Service", "DocStatus", "Trade", "Problem", "Priority", "InternalWONumber", "Status" + WorkOrderFieldNames.LifecycleStatus, + WorkOrderFieldNames.AssignTo, + WorkOrderFieldNames.ScheduledDate, + WorkOrderFieldNames.ScheduledEnd, + WorkOrderFieldNames.TargetWeek, + WorkOrderFieldNames.ScheduleWeekOnly, + WorkOrderFieldNames.DueDate, + WorkOrderFieldNames.Description, + WorkOrderFieldNames.WorkerOrderTitle, + WorkOrderFieldNames.SiteCode, + WorkOrderFieldNames.Building, + WorkOrderFieldNames.LocationId, + WorkOrderFieldNames.WorkOrderType, + WorkOrderFieldNames.Service, + WorkOrderFieldNames.DocStatus, + WorkOrderFieldNames.Trade, + WorkOrderFieldNames.Problem, + WorkOrderFieldNames.Priority, + WorkOrderFieldNames.InternalWONumber, + WorkOrderFieldNames.Status, }; private readonly IWorkOrderFieldLockService _fieldLocks; - private readonly IWorkOrderAuditService _audit; - public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit) + public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks) { _fieldLocks = fieldLocks; - _audit = audit; } public IReadOnlySet ShocOwnedFields => ShocOwnedFieldSet; - public bool CanSyncOverwrite(int workOrderId, string fieldName) - => !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult(); + public async Task CanSyncOverwriteAsync(int workOrderId, string fieldName) + => !await _fieldLocks.IsLockedAsync(workOrderId, fieldName); public async Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue) { @@ -41,52 +58,162 @@ namespace SeaHaven.Services.Implementation return false; } - ApplyField(context.WorkOrder, fieldName, incomingValue); - return true; + return TryApplyField(context.WorkOrder, fieldName, incomingValue); } private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch { - "Description" => wo.Description, - "WorkerOrderTitle" => wo.WorkerOrderTitle, - "Status" => wo.Status, - "DueDate" => wo.DueDate?.ToString("O"), - "SiteCode" => wo.SiteCode, - "Building" => wo.Building, - "Priority" => wo.Priority, - "Severity" => wo.Severity, + WorkOrderFieldNames.Description => wo.Description, + WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle, + WorkOrderFieldNames.Status => wo.Status, + WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"), + WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"), + WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"), + WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"), + WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(), + WorkOrderFieldNames.SiteCode => wo.SiteCode, + WorkOrderFieldNames.Building => wo.Building, + WorkOrderFieldNames.Priority => wo.Priority, + WorkOrderFieldNames.AssignTo => wo.AssignTo, + WorkOrderFieldNames.LocationId => wo.LocationId?.ToString(), + WorkOrderFieldNames.LifecycleStatus => wo.LifecycleStatus?.ToString(), + WorkOrderFieldNames.WorkOrderType => wo.WorkOrderType?.ToString(), + WorkOrderFieldNames.Service => wo.Service, + WorkOrderFieldNames.DocStatus => wo.DocStatus?.ToString(), + WorkOrderFieldNames.Trade => wo.Trade, + WorkOrderFieldNames.Problem => wo.Problem, + WorkOrderFieldNames.InternalWONumber => wo.InternalWONumber, _ => null }; - private static void ApplyField(WorkOrder wo, string fieldName, object incomingValue) + private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue) { switch (fieldName) { - case "Description": + case WorkOrderFieldNames.Description: wo.Description = incomingValue.ToString(); - break; - case "WorkerOrderTitle": + return true; + case WorkOrderFieldNames.WorkerOrderTitle: wo.WorkerOrderTitle = incomingValue.ToString(); - break; - case "Status": + return true; + case WorkOrderFieldNames.Status: wo.Status = incomingValue.ToString(); - break; - case "DueDate" when incomingValue is DateTime dt: - wo.DueDate = dt; - break; - case "SiteCode": + return true; + case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate): + wo.DueDate = dueDate; + return true; + case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate): + wo.ScheduledDate = scheduledDate; + return true; + case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd): + wo.ScheduledEnd = scheduledEnd; + return true; + case WorkOrderFieldNames.TargetWeek when TryParseDateOnly(incomingValue, out var targetWeek): + wo.TargetWeek = targetWeek; + return true; + case WorkOrderFieldNames.ScheduleWeekOnly when TryParseBool(incomingValue, out var scheduleWeekOnly): + wo.ScheduleWeekOnly = scheduleWeekOnly; + return true; + case WorkOrderFieldNames.SiteCode: wo.SiteCode = incomingValue.ToString(); - break; - case "Building": + return true; + case WorkOrderFieldNames.Building: wo.Building = incomingValue.ToString(); - break; - case "Priority": + return true; + case WorkOrderFieldNames.Priority: wo.Priority = incomingValue.ToString(); - break; - case "Severity": - wo.Severity = incomingValue.ToString(); - break; + return true; + case WorkOrderFieldNames.AssignTo: + wo.AssignTo = incomingValue.ToString(); + return true; + case WorkOrderFieldNames.LocationId when TryParseInt(incomingValue, out var locationId): + wo.LocationId = locationId; + return true; + case WorkOrderFieldNames.LifecycleStatus when TryParseEnum(incomingValue, out LifecycleStatus lifecycleStatus): + wo.LifecycleStatus = lifecycleStatus; + return true; + case WorkOrderFieldNames.WorkOrderType when TryParseEnum(incomingValue, out WorkOrderType workOrderType): + wo.WorkOrderType = workOrderType; + return true; + case WorkOrderFieldNames.Service: + wo.Service = incomingValue.ToString(); + return true; + case WorkOrderFieldNames.DocStatus when TryParseEnum(incomingValue, out DocStatus docStatus): + wo.DocStatus = docStatus; + return true; + case WorkOrderFieldNames.Trade: + wo.Trade = incomingValue.ToString(); + return true; + case WorkOrderFieldNames.Problem: + wo.Problem = incomingValue.ToString(); + return true; + case WorkOrderFieldNames.InternalWONumber: + wo.InternalWONumber = incomingValue.ToString(); + return true; + default: + return false; } } + + private static bool TryParseDateTime(object value, out DateTime result) + { + if (value is DateTime dt) + { + result = dt; + return true; + } + + return DateTime.TryParse(value.ToString(), out result); + } + + private static bool TryParseDateOnly(object value, out DateOnly result) + { + if (value is DateOnly dateOnly) + { + result = dateOnly; + return true; + } + + if (value is DateTime dt) + { + result = DateOnly.FromDateTime(dt); + return true; + } + + return DateOnly.TryParse(value.ToString(), out result); + } + + private static bool TryParseBool(object value, out bool result) + { + if (value is bool b) + { + result = b; + return true; + } + + return bool.TryParse(value.ToString(), out result); + } + + private static bool TryParseInt(object value, out int result) + { + if (value is int i) + { + result = i; + return true; + } + + return int.TryParse(value.ToString(), out result); + } + + private static bool TryParseEnum(object value, out TEnum result) where TEnum : struct, Enum + { + if (value is TEnum enumValue) + { + result = enumValue; + return true; + } + + return Enum.TryParse(value.ToString(), true, out result); + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 4543c91..19df98b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Constants; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -18,11 +19,11 @@ namespace SeaHaven.Services.Implementation public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) => LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual, - AuditActorType.Dispatcher, "LifecycleStatus", oldStatus, newStatus, actorId); + AuditActorType.Dispatcher, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId); public Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) => LogAsync(workOrderId, AuditActionType.AssignmentChanged, AuditEventType.Manual, - AuditActorType.Dispatcher, "AssignTo", oldValue, newValue, actorId); + AuditActorType.Dispatcher, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId); public Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue) => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, @@ -63,7 +64,29 @@ namespace SeaHaven.Services.Implementation await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); } - await _context.SaveChangesAsync(); + await SaveChangesIdempotentAsync(); + } + + private async Task SaveChangesIdempotentAsync() + { + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) + { + foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock)) + entry.State = EntityState.Detached; + + await _context.SaveChangesAsync(); + } + } + + private static bool IsUniqueConstraintViolation(DbUpdateException ex) + { + var message = ex.InnerException?.Message ?? ex.Message; + return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) + || message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase); } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs index d132ea8..442fbb3 100644 --- a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs @@ -15,14 +15,20 @@ namespace SeaHaven.Services.Implementation public async Task IsLockedAsync(int workOrderId, string fieldName) { + if (IsTracked(workOrderId, fieldName)) + return true; + return await _context.WorkOrderFieldLocks .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); } public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId) { - var exists = await IsLockedAsync(workOrderId, fieldName); - if (exists) return; + if (IsTracked(workOrderId, fieldName)) + return; + + if (await IsLockedAsync(workOrderId, fieldName)) + return; _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock { @@ -31,7 +37,11 @@ namespace SeaHaven.Services.Implementation LockedAt = DateTime.UtcNow, LockedByUserId = userId }); - await _context.SaveChangesAsync(); } + + private bool IsTracked(int workOrderId, string fieldName) + => _context.WorkOrderFieldLocks.Local.Any(l => + l.WorkOrderId == workOrderId && + string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase)); } } diff --git a/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs index 96b401d..12f9398 100644 --- a/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs +++ b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs @@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces { IReadOnlySet ShocOwnedFields { get; } - bool CanSyncOverwrite(int workOrderId, string fieldName); + Task CanSyncOverwriteAsync(int workOrderId, string fieldName); Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue); } diff --git a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs index cd763f4..0a06141 100644 --- a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs +++ b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Constants; using SeaHaven.Services.Helpers; namespace SeaHavenIndustries.Tests; @@ -23,10 +24,10 @@ public class LifecycleStatusMapperTests } [Fact] - public void FromLegacyStatus_Unknown_ReturnsIncomplete() + public void FromLegacyStatus_Unknown_ReturnsNull() { var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus"); - Assert.Equal(LifecycleStatus.Incomplete, result); + Assert.Null(result); } [Theory] diff --git a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs index e88f262..e9831d7 100644 --- a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs +++ b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -24,14 +26,14 @@ public class SyncFieldMergePolicyTests context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock { WorkOrderId = 10, - FieldName = "Description", + FieldName = WorkOrderFieldNames.Description, LockedAt = DateTime.UtcNow }); await context.SaveChangesAsync(); var locks = new WorkOrderFieldLockService(context); var audit = new WorkOrderAuditService(context, locks); - var policy = new SyncFieldMergePolicy(locks, audit); + var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext { @@ -40,7 +42,7 @@ public class SyncFieldMergePolicyTests Audit = audit }; - var applied = await policy.TryApplyAsync(syncContext, "Description", "Sync value"); + var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "Sync value"); Assert.False(applied); Assert.Equal("SHOC edited", wo.Description); @@ -61,7 +63,7 @@ public class SyncFieldMergePolicyTests var locks = new WorkOrderFieldLockService(context); var audit = new WorkOrderAuditService(context, locks); - var policy = new SyncFieldMergePolicy(locks, audit); + var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext { @@ -70,9 +72,80 @@ public class SyncFieldMergePolicyTests Audit = audit }; - var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync"); + var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "From sync"); Assert.True(applied); Assert.Equal("From sync", wo.Description); } + + [Fact] + public async Task TryApply_UnsupportedField_ReturnsFalse() + { + await using var context = CreateContext(); + var wo = new WorkOrder { Id = 12 }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var locks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, locks); + var policy = new SyncFieldMergePolicy(locks); + + var syncContext = new WorkOrderSyncContext + { + WorkOrder = wo, + FieldLocks = locks, + Audit = audit + }; + + var applied = await policy.TryApplyAsync(syncContext, "NotARealField", "value"); + + Assert.False(applied); + } + + [Theory] + [InlineData(WorkOrderFieldNames.LifecycleStatus, LifecycleStatus.Scheduled)] + [InlineData(WorkOrderFieldNames.Service, "PM")] + [InlineData(WorkOrderFieldNames.DueDate, "2026-07-01T10:00:00Z")] + public async Task TryApply_ShocOwnedFields_AppliesValue(string fieldName, object incomingValue) + { + await using var context = CreateContext(); + var wo = new WorkOrder { Id = 13 }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var locks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, locks); + var policy = new SyncFieldMergePolicy(locks); + + var syncContext = new WorkOrderSyncContext + { + WorkOrder = wo, + FieldLocks = locks, + Audit = audit + }; + + var applied = await policy.TryApplyAsync(syncContext, fieldName, incomingValue); + + Assert.True(applied); + } + + [Fact] + public async Task CanSyncOverwriteAsync_WhenLocked_ReturnsFalse() + { + await using var context = CreateContext(); + context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = 20, + FieldName = WorkOrderFieldNames.AssignTo, + LockedAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + var locks = new WorkOrderFieldLockService(context); + var policy = new SyncFieldMergePolicy(locks); + + var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo); + + Assert.False(canOverwrite); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs index f3058c3..f7df5ed 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -33,7 +34,7 @@ public class WorkOrderAuditServiceTests Assert.Equal("StatusChanged", logs[0].Action); Assert.Equal("Manual", logs[0].EventType); Assert.Equal("Dispatcher", logs[0].ActorType); - Assert.Equal("LifecycleStatus", logs[0].FieldName); + Assert.Equal(WorkOrderFieldNames.LifecycleStatus, logs[0].FieldName); } [Fact] @@ -50,6 +51,6 @@ public class WorkOrderAuditServiceTests var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync(); Assert.Single(fieldLocks); - Assert.Equal("LifecycleStatus", fieldLocks[0].FieldName); + Assert.Equal(WorkOrderFieldNames.LifecycleStatus, fieldLocks[0].FieldName); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs new file mode 100644 index 0000000..b598a4d --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs @@ -0,0 +1,53 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Constants; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderFieldLockServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock() + { + await using var context = CreateContext(); + var locks = new WorkOrderFieldLockService(context); + + await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1"); + await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2"); + await context.SaveChangesAsync(); + + var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Single(fieldLocks); + Assert.Equal(WorkOrderFieldNames.Description, fieldLocks[0].FieldName); + } + + [Fact] + public async Task LockFieldAsync_WhenAlreadyPersisted_IsIdempotent() + { + await using var context = CreateContext(); + context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = 2, + FieldName = WorkOrderFieldNames.AssignTo, + LockedAt = DateTime.UtcNow, + LockedByUserId = "user-1" + }); + await context.SaveChangesAsync(); + + var locks = new WorkOrderFieldLockService(context); + await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2"); + await context.SaveChangesAsync(); + + var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Single(fieldLocks); + } +}