From ac5550365f07f443cbd22746ae0a2e65d88852aa Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 23:58:20 -0300 Subject: [PATCH 1/3] fix: recalculate add-on on work order reschedule --- .../Controllers/WorkOrderController.cs | 1 + SeaHaven.Services/DTOs/WorkOrderDTOs.cs | 2 + .../DTOs/WorkOrderServiceDTOs.cs | 1 + .../Implementation/WorkOrderService.cs | 16 ++++ .../WorkOrderEditAddonRescheduleTests.cs | 78 +++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9c73034..82795a3 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -100,6 +100,7 @@ namespace Api.SeaHavenIndustries.Controllers LocationId = model.LocationId, AssignTo = model.AssignTo, DueDate = model.DueDate, + ScheduledDate = model.ScheduledDate, CompletedDate = model.CompletedDate, ContactIds = model.ContactIds, CategoryIds = model.CategoryIds, diff --git a/SeaHaven.Services/DTOs/WorkOrderDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs index f4f6003..fab9492 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs @@ -111,6 +111,7 @@ namespace SeaHaven.Services.DTOs public int? LocationId { get; set; } public string? AssignTo { get; set; } public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } public DateTime? CompletedDate { get; set; } public static void UpdateEntity(UpdateWorkOrderDTO dto, WorkOrder entity) @@ -124,6 +125,7 @@ namespace SeaHaven.Services.DTOs if (dto.LocationId.HasValue) entity.LocationId = dto.LocationId; if (dto.AssignTo != null) entity.AssignTo = dto.AssignTo; if (dto.DueDate.HasValue) entity.DueDate = dto.DueDate; + if (dto.ScheduledDate.HasValue) entity.ScheduledDate = dto.ScheduledDate.Value.Date; if (dto.CompletedDate.HasValue) entity.CompletedDate = dto.CompletedDate; entity.LastModificationTime = DateTime.UtcNow; diff --git a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs index 7665da4..fb59789 100644 --- a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs @@ -32,6 +32,7 @@ namespace SeaHaven.Services.DTOs public int? LocationId { get; set; } public string? AssignTo { get; set; } public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } public DateTime? CompletedDate { get; set; } public List? ContactIds { get; set; } public List? CategoryIds { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 6cfa57f..19695b2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -6,6 +6,7 @@ using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; @@ -105,7 +106,19 @@ namespace SeaHaven.Services.Implementation var changes = new List(); changes.AddRange(TrackChanges(existingWorkOrder, dto)); + var scheduledDateChanged = dto.ScheduledDate.HasValue + && dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date; + var previousIsAddOn = existingWorkOrder.IsAddOn; UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); + if (scheduledDateChanged) + { + existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( + existingWorkOrder.CreatedDate ?? DateTime.UtcNow, + existingWorkOrder.ScheduledDate, + existingWorkOrder.TargetWeek); + if (existingWorkOrder.IsAddOn != previousIsAddOn) + changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}"); + } await _workOrderDataService.UpdateAsync(existingWorkOrder); if (changes.Any()) @@ -405,6 +418,7 @@ namespace SeaHaven.Services.Implementation LocationId = input.LocationId, AssignTo = input.AssignTo, DueDate = input.DueDate, + ScheduledDate = input.ScheduledDate, CompletedDate = input.CompletedDate }; @@ -630,6 +644,8 @@ namespace SeaHaven.Services.Implementation changes.Add($"Assigned To: {existing.AssignTo} → {dto.AssignTo}"); if (dto.DueDate.HasValue && dto.DueDate != existing.DueDate) changes.Add($"Due Date: {existing.DueDate:yyyy-MM-dd} → {dto.DueDate:yyyy-MM-dd}"); + if (dto.ScheduledDate.HasValue && dto.ScheduledDate.Value.Date != existing.ScheduledDate?.Date) + changes.Add($"Schedule On: {existing.ScheduledDate:yyyy-MM-dd} → {dto.ScheduledDate:yyyy-MM-dd}"); return changes; } diff --git a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs new file mode 100644 index 0000000..e595ea7 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs @@ -0,0 +1,78 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderEditAddonRescheduleTests +{ + [Fact] + public async Task EditWorkorder_RescheduleIntoEarlierWeek_EnablesAndAuditsAddOn() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + var workOrder = new WorkOrder + { + Id = 1, + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + ScheduledDate = new DateTime(2026, 6, 29), + IsAddOn = false + }; + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + + var accountResolver = WorkOrderAccountTestHelpers.Resolver(context); + var workOrderService = new WorkOrderService( + new WorkOrderDataService(context), + new CommentDataService(context), + new UserDataService(context), + new QuotesDataService(context), + fileStorage: null!, + new CreateWorkOrderValidation(), + new UpdateWorkOrderValidation(), + accountResolver); + var controller = new WorkOrderController( + workOrderService, + accountResolver, + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") }, + authenticationType: "test")) + } + } + }; + + var result = await controller.Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 25) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate); + Assert.True(persisted.IsAddOn); + var audit = await context.WorkOrderAuditLogs + .Where(x => x.WorkOrderId == 1) + .ToListAsync(); + Assert.Single(audit); + Assert.Contains(audit, x => x.FieldName?.Contains("Schedule On", StringComparison.Ordinal) == true); + Assert.Contains(audit, x => x.FieldName?.Contains("IsAddOn", StringComparison.Ordinal) == true); + } +} From 24436c9864617ea05c76a9a404c9e3b046b57a21 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 1 Oct 2026 00:10:43 -0300 Subject: [PATCH 2/3] fix: apply shared schedule side effects on work order edit Route edit-path ScheduledDate changes through WorkOrderBoardFieldMutations.ApplyScheduledDate so OriginalDate/OriginalWeek, RescheduleCount and lifecycle promotion match the board PATCH path, and audit those changes. --- SeaHaven.Services/DTOs/WorkOrderDTOs.cs | 1 - .../Implementation/WorkOrderService.cs | 7 ++ .../WorkOrderEditAddonRescheduleTests.cs | 86 +++++++++++++------ 3 files changed, 68 insertions(+), 26 deletions(-) diff --git a/SeaHaven.Services/DTOs/WorkOrderDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs index fab9492..41b5518 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs @@ -125,7 +125,6 @@ namespace SeaHaven.Services.DTOs if (dto.LocationId.HasValue) entity.LocationId = dto.LocationId; if (dto.AssignTo != null) entity.AssignTo = dto.AssignTo; if (dto.DueDate.HasValue) entity.DueDate = dto.DueDate; - if (dto.ScheduledDate.HasValue) entity.ScheduledDate = dto.ScheduledDate.Value.Date; if (dto.CompletedDate.HasValue) entity.CompletedDate = dto.CompletedDate; entity.LastModificationTime = DateTime.UtcNow; diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 19695b2..4852185 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -112,6 +112,13 @@ namespace SeaHaven.Services.Implementation UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); if (scheduledDateChanged) { + var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate( + existingWorkOrder, + dto.ScheduledDate!.Value.Date, + WorkOrderBoardFieldNames.ScheduledDate); + changes.AddRange(scheduleChanges + .Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate) + .Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}")); existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( existingWorkOrder.CreatedDate ?? DateTime.UtcNow, existingWorkOrder.ScheduledDate, diff --git a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs index e595ea7..3646f0b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,20 +19,72 @@ public sealed class WorkOrderEditAddonRescheduleTests [Fact] public async Task EditWorkorder_RescheduleIntoEarlierWeek_EnablesAndAuditsAddOn() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using var context = new ApplicationDbContext(options); - var workOrder = new WorkOrder + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder { Id = 1, CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), ScheduledDate = new DateTime(2026, 6, 29), IsAddOn = false - }; - context.workOrders.Add(workOrder); + }); await context.SaveChangesAsync(); + var result = await CreateController(context).Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 25) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate); + Assert.True(persisted.IsAddOn); + Assert.Equal(1, persisted.RescheduleCount); + var audit = await context.WorkOrderAuditLogs + .Where(x => x.WorkOrderId == 1) + .ToListAsync(); + Assert.Single(audit); + Assert.Contains(audit, x => x.FieldName?.Contains("Schedule On", StringComparison.Ordinal) == true); + Assert.Contains(audit, x => x.FieldName?.Contains("IsAddOn", StringComparison.Ordinal) == true); + Assert.Contains(audit, x => x.FieldName?.Contains("RescheduleCount: 0 → 1", StringComparison.Ordinal) == true); + } + + [Fact] + public async Task EditWorkorder_FirstSchedule_SetsOriginalWeekAndPromotesIncomplete() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Incomplete + }); + await context.SaveChangesAsync(); + + var result = await CreateController(context).Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 7, 1) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.Equal(new DateOnly(2026, 7, 1), persisted.OriginalDate); + Assert.Equal(new DateOnly(2026, 6, 29), persisted.OriginalWeek); + Assert.Equal(0, persisted.RescheduleCount); + Assert.Equal(LifecycleStatus.Scheduled, persisted.LifecycleStatus); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderController CreateController(ApplicationDbContext context) + { var accountResolver = WorkOrderAccountTestHelpers.Resolver(context); var workOrderService = new WorkOrderService( new WorkOrderDataService(context), @@ -42,7 +95,7 @@ public sealed class WorkOrderEditAddonRescheduleTests new CreateWorkOrderValidation(), new UpdateWorkOrderValidation(), accountResolver); - var controller = new WorkOrderController( + return new WorkOrderController( workOrderService, accountResolver, NullLogger.Instance) @@ -57,22 +110,5 @@ public sealed class WorkOrderEditAddonRescheduleTests } } }; - - var result = await controller.Editworkorder(new EditWorkorder_DTO - { - Id = 1, - ScheduledDate = new DateTime(2026, 6, 25) - }); - - Assert.IsType(result); - var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); - Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate); - Assert.True(persisted.IsAddOn); - var audit = await context.WorkOrderAuditLogs - .Where(x => x.WorkOrderId == 1) - .ToListAsync(); - Assert.Single(audit); - Assert.Contains(audit, x => x.FieldName?.Contains("Schedule On", StringComparison.Ordinal) == true); - Assert.Contains(audit, x => x.FieldName?.Contains("IsAddOn", StringComparison.Ordinal) == true); } } From c02749a4ea1fb143037ce494f599e3ff1f60b574 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 1 Oct 2026 00:19:37 -0300 Subject: [PATCH 3/3] fix: run work order edit reschedule under the mutation lock UpdateWorkOrderAsync loaded the work order, applied the shared schedule side effects (RescheduleCount, OriginalDate/OriginalWeek, lifecycle, IsAddOn) and saved with no gate, transaction or row lock, so a concurrent reschedule could interleave with the read-modify-write. The read, the mutation, the save and the audit entry now run through IWorkOrderDataService.ExecuteWorkOrderMutationAsync, which uses the shared WorkOrderMutationLock: the per-work-order in-process gate, a transaction, and an UPDLOCK on the work order row on SQL Server. A reschedule committed by another holder of the lock is read before the edit applies its own date and increments RescheduleCount. Adds a regression test that holds the gate, commits a reschedule, and asserts the edit waits and then builds on the committed schedule. --- .../Implementation/WorkOrderDataService.cs | 6 ++ .../Interfaces/IWorkOrderDataService.cs | 6 ++ .../Implementation/WorkOrderService.cs | 67 +++++++++++-------- .../WorkOrderEditAddonRescheduleTests.cs | 66 +++++++++++++++++- 4 files changed, 114 insertions(+), 31 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index b34db11..9a305f5 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -206,6 +206,12 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + public Task ExecuteWorkOrderMutationAsync( + int workOrderId, + Func> work, + CancellationToken cancellationToken) + => WorkOrderMutationLock.RunAsync(_context, workOrderId, work, cancellationToken); + public async Task DeleteAsync(int id) { var entity = await GetByIdAsync(id); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index d1fb67f..1211c60 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -39,6 +39,12 @@ namespace SeaHaven.DataServices.Interfaces Task AddAsync(WorkOrder workOrder); Task UpdateAsync(WorkOrder workOrder); Task DeleteAsync(int id); + // Runs a read-modify-write on one work order under the shared per-work-order gate, + // transaction and row lock (WorkOrderMutationLock). + Task ExecuteWorkOrderMutationAsync( + int workOrderId, + Func> work, + CancellationToken cancellationToken); // Related entities - Read Task> GetWorkOrderContactsAsync(int workOrderId); diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 4852185..132cbe5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -99,39 +99,48 @@ namespace SeaHaven.Services.Implementation if (!validationResult.IsValid) throw new ValidationException(validationResult.Errors); - var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id); - if (existingWorkOrder == null) - return false; + // The read, the schedule side effects (RescheduleCount, OriginalDate/Week, IsAddOn) + // and the audit entry run under the work order's mutation gate and row lock, so a + // concurrent reschedule cannot interleave with this read-modify-write. + return await _workOrderDataService.ExecuteWorkOrderMutationAsync( + id, + async _ => + { + var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id); + if (existingWorkOrder == null) + return false; - var changes = new List(); - changes.AddRange(TrackChanges(existingWorkOrder, dto)); + var changes = new List(); + changes.AddRange(TrackChanges(existingWorkOrder, dto)); - var scheduledDateChanged = dto.ScheduledDate.HasValue - && dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date; - var previousIsAddOn = existingWorkOrder.IsAddOn; - UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); - if (scheduledDateChanged) - { - var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate( - existingWorkOrder, - dto.ScheduledDate!.Value.Date, - WorkOrderBoardFieldNames.ScheduledDate); - changes.AddRange(scheduleChanges - .Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate) - .Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}")); - existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( - existingWorkOrder.CreatedDate ?? DateTime.UtcNow, - existingWorkOrder.ScheduledDate, - existingWorkOrder.TargetWeek); - if (existingWorkOrder.IsAddOn != previousIsAddOn) - changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}"); - } - await _workOrderDataService.UpdateAsync(existingWorkOrder); + var scheduledDateChanged = dto.ScheduledDate.HasValue + && dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date; + var previousIsAddOn = existingWorkOrder.IsAddOn; + UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); + if (scheduledDateChanged) + { + var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate( + existingWorkOrder, + dto.ScheduledDate!.Value.Date, + WorkOrderBoardFieldNames.ScheduledDate); + changes.AddRange(scheduleChanges + .Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate) + .Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}")); + existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( + existingWorkOrder.CreatedDate ?? DateTime.UtcNow, + existingWorkOrder.ScheduledDate, + existingWorkOrder.TargetWeek); + if (existingWorkOrder.IsAddOn != previousIsAddOn) + changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}"); + } + await _workOrderDataService.UpdateAsync(existingWorkOrder); - if (changes.Any()) - await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes)); + if (changes.Any()) + await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes)); - return true; + return true; + }, + CancellationToken.None); } public async Task DeleteWorkOrderAsync(int id, string userId) diff --git a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs index 3646f0b..328e4f2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs @@ -75,10 +75,72 @@ public sealed class WorkOrderEditAddonRescheduleTests Assert.Equal(LifecycleStatus.Scheduled, persisted.LifecycleStatus); } - private static ApplicationDbContext CreateContext() + [Fact] + public async Task EditWorkorder_Reschedule_WaitsForWorkOrderMutationGateAndReadsCommittedSchedule() + { + const int workOrderId = 41201; + var databaseName = Guid.NewGuid().ToString(); + await using (var seed = CreateContext(databaseName)) + { + seed.workOrders.Add(new WorkOrder + { + Id = workOrderId, + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + ScheduledDate = new DateTime(2026, 7, 6), + OriginalDate = new DateOnly(2026, 7, 6), + IsAddOn = false + }); + await seed.SaveChangesAsync(); + } + + // A concurrent reschedule holds the work order's mutation gate and commits its own + // date while the edit is waiting. + await using var holderContext = CreateContext(databaseName); + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync( + workOrderId, + async ct => + { + held.SetResult(); + await release.Task; + var workOrder = await holderContext.workOrders.SingleAsync(x => x.Id == workOrderId, ct); + workOrder.ScheduledDate = new DateTime(2026, 6, 29); + workOrder.RescheduleCount++; + await holderContext.SaveChangesAsync(ct); + return true; + }, + CancellationToken.None); + await held.Task; + + await using var editContext = CreateContext(databaseName); + var edit = CreateController(editContext).Editworkorder(new EditWorkorder_DTO + { + Id = workOrderId, + ScheduledDate = new DateTime(2026, 6, 25) + }); + await Task.Delay(200); + + Assert.False(edit.IsCompleted); + + release.SetResult(); + await holder; + Assert.IsType(await edit); + + await using var verify = CreateContext(databaseName); + var persisted = await verify.workOrders.SingleAsync(x => x.Id == workOrderId); + Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate); + Assert.Equal(2, persisted.RescheduleCount); + Assert.True(persisted.IsAddOn); + var audit = await verify.WorkOrderAuditLogs.SingleAsync(x => x.WorkOrderId == workOrderId); + Assert.Contains("Schedule On: 2026-06-29 → 2026-06-25", audit.FieldName); + Assert.Contains("RescheduleCount: 1 → 2", audit.FieldName); + } + + private static ApplicationDbContext CreateContext(string? databaseName = null) { var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .UseInMemoryDatabase(databaseName ?? Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); }