shoc-backend/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs
Alexandre Brandizzi c02749a4ea 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.
2026-10-01 00:19:37 -03:00

176 lines
7 KiB
C#

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;
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()
{
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
});
await context.SaveChangesAsync();
var result = await CreateController(context).Editworkorder(new EditWorkorder_DTO
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 25)
});
Assert.IsType<OkObjectResult>(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<OkObjectResult>(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);
}
[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<OkObjectResult>(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<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName ?? 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),
new CommentDataService(context),
new UserDataService(context),
new QuotesDataService(context),
fileStorage: null!,
new CreateWorkOrderValidation(),
new UpdateWorkOrderValidation(),
accountResolver);
return new WorkOrderController(
workOrderService,
accountResolver,
NullLogger<WorkOrderController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") },
authenticationType: "test"))
}
}
};
}
}