shoc-backend/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs
Alexandre Brandizzi 9bad363930 fix(work-orders): cancelling from the board cancels the pending uplift
The board and slide-over cancel a work order through the lifecycle status
patch, which set Canceled without touching uplifts, so a pending uplift
stayed in the approval queue. A patch to Canceled now withdraws pending
uplifts in the same save, each with its own uplift_cancel audit entry, and
runs under the per-work-order gate uplift create uses.
2026-09-25 18:37:56 -03:00

170 lines
6.9 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
/// <summary>SH-169 / SH-357 / SH-358: automatic lifecycle states cannot be forged through the board PATCH.</summary>
public class WorkOrderBoardPatchLifecycleRulesTests
{
private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 };
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = WorkOrderAccountTestHelpers.BoardUpdateService(
context,
audit);
return (context, service);
}
private static async Task SeedAsync(
ApplicationDbContext context,
LifecycleStatus status,
DateTime? scheduledDate = null)
{
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = status,
ScheduledDate = scheduledDate,
ScheduleWeekOnly = false,
RowVersion = Version
});
await context.SaveChangesAsync();
}
private static Task<WorkOrderBoardRowDto> PatchStatusAsync(WorkOrderBoardUpdateService service, string value)
=> service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = value,
WorkOrderVersion = Convert.ToBase64String(Version)
}, "actor-1");
[Theory]
[InlineData(LifecycleStatus.Pending, "Incomplete")]
[InlineData(LifecycleStatus.InProgress, "Incomplete")]
[InlineData(LifecycleStatus.EnRoute, "incomplete")]
[InlineData(LifecycleStatus.OnSite, "1")]
[InlineData(LifecycleStatus.Pending, "Open")]
public async Task PatchField_IncompleteRequestFromAnotherState_IsRejectedWithoutChangeOrAudit(
LifecycleStatus current,
string requested)
{
var (context, service) = CreateSut();
await SeedAsync(context, current);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(
() => PatchStatusAsync(service, requested));
Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Theory]
[InlineData(LifecycleStatus.EnRoute)]
[InlineData(LifecycleStatus.OnSite)]
[InlineData(LifecycleStatus.InProgress)]
[InlineData(LifecycleStatus.PendingQuote)]
public async Task PatchField_ScheduledRequestWhenDerivationWouldNotPromote_IsRejected(LifecycleStatus current)
{
var (context, service) = CreateSut();
await SeedAsync(context, current, DateTime.UtcNow.Date.AddDays(7));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(
() => PatchStatusAsync(service, "Scheduled"));
Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Theory]
[InlineData("Past Due")]
[InlineData("PastDue")]
public async Task PatchField_PastDueIsNotARequestableLifecycleStatus(string requested)
{
var (context, service) = CreateSut();
await SeedAsync(context, LifecycleStatus.Pending);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(
() => PatchStatusAsync(service, requested));
Assert.Equal("InvalidValue", ex.Code);
Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_RestatingDerivedScheduledStatus_IsANoOp()
{
var (context, service) = CreateSut();
await SeedAsync(context, LifecycleStatus.Scheduled, DateTime.UtcNow.Date.AddDays(7));
var result = await PatchStatusAsync(service, "Scheduled");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Theory]
[InlineData(LifecycleStatus.Incomplete, "Pending")]
[InlineData(LifecycleStatus.Pending, "En Route")]
[InlineData(LifecycleStatus.EnRoute, "On Site")]
[InlineData(LifecycleStatus.OnSite, "In Progress")]
[InlineData(LifecycleStatus.InProgress, "Pending")]
public async Task PatchField_ManualTransition_PersistsAndAuditsActorPreviousAndNew(
LifecycleStatus current,
string requested)
{
var (context, service) = CreateSut();
await SeedAsync(context, current);
var result = await PatchStatusAsync(service, requested);
var expected = LifecycleStatusMapper.ParseLifecycleStatus(requested)!.Value;
Assert.Equal(expected, result.LifecycleStatus);
var audit = Assert.Single(await context.WorkOrderAuditLogs.ToListAsync());
Assert.Equal(AuditActionType.StatusChanged.ToString(), audit.Action);
Assert.Equal("actor-1", audit.UserId);
Assert.Equal(current.ToString(), audit.OldValue);
Assert.Equal(expected.ToString(), audit.NewValue);
Assert.NotEqual(default, audit.CreatedAt);
}
[Theory]
[InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Incomplete, false)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.Incomplete, true)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.Pending, false)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.EnRoute, false)]
[InlineData(LifecycleStatus.InProgress, LifecycleStatus.Scheduled, true)]
[InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Scheduled, false)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.Scheduled, false)]
public void EnsureRequestAllowed_WithConcreteSchedule(
LifecycleStatus current,
LifecycleStatus requested,
bool rejects)
{
var date = new DateTime(2026, 9, 21);
var ex = Record.Exception(() =>
WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(current, requested, date, false));
if (rejects)
Assert.Equal(
WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode,
Assert.IsType<WorkOrderBoardValidationException>(ex).Code);
else
Assert.Null(ex);
}
}