fix(work-orders): address PR review on sync, audit, lock and mapper

This commit is contained in:
Arthur Bassi 2026-07-02 09:30:05 -03:00
parent 76e839addf
commit e5902e1df3
11 changed files with 452 additions and 142 deletions

View file

@ -4,10 +4,6 @@ using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// Work Order aggregate root. Domain slices (Core, Scheduling, Tracking, Analytics, Completion)
/// are persisted as flat columns on workOrders for Fase 0 backward compatibility.
/// </summary>
public class WorkOrder : FullAuditEntity
{
// --- Core slice ---

View file

@ -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);
}
}

View file

@ -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<LifecycleStatus>(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<LifecycleStatus>(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;
}
}
}

View file

@ -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<string> 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<string> ShocOwnedFields => ShocOwnedFieldSet;
public bool CanSyncOverwrite(int workOrderId, string fieldName)
=> !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult();
public async Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName)
=> !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
public async Task<bool> 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<TEnum>(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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -15,14 +15,20 @@ namespace SeaHaven.Services.Implementation
public async Task<bool> 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));
}
}

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
{
IReadOnlySet<string> ShocOwnedFields { get; }
bool CanSyncOverwrite(int workOrderId, string fieldName);
Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName);
Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
}

View file

@ -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]

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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<ApplicationDbContext>()
.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);
}
}