diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs new file mode 100644 index 0000000..b963b73 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs @@ -0,0 +1,52 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + /// + /// SH-169 manual transition guard for the board lifecycleStatus PATCH. + /// Incomplete and Scheduled are derived by the server; a direct request may only + /// restate what derivation already produces, never forge a new automatic state. + /// Past Due is a read-model overlay (), + /// not a value, so it cannot be requested at all. + /// + public static class WorkOrderBoardPatchLifecycleRules + { + public const string AutomaticStatusCode = "AutomaticLifecycleStatus"; + + public static bool IsAutomaticStatus(LifecycleStatus status) + => status == LifecycleStatus.Incomplete || status == LifecycleStatus.Scheduled; + + public static void EnsureRequestAllowed( + LifecycleStatus? current, + LifecycleStatus requested, + DateTime? scheduledDate, + bool? scheduleWeekOnly) + { + if (!IsAutomaticStatus(requested)) + return; + + if (requested == LifecycleStatus.Scheduled + && !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly)) + { + throw new WorkOrderBoardValidationException( + "ScheduledRequiresDate", + "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true."); + } + + // why: the board client restates the derived status after a schedule patch; that is a no-op, not a forge. + if (current == requested) + return; + + if (requested == LifecycleStatus.Scheduled + && WorkOrderBoardMutationRules.ShouldPromoteToScheduled(current, scheduledDate, scheduleWeekOnly)) + { + return; + } + + throw new WorkOrderBoardValidationException( + AutomaticStatusCode, + $"{LifecycleStatusMapper.ToFeLabel(requested)} is set automatically and cannot be selected manually."); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 18604b2..533ece6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -516,15 +516,11 @@ namespace SeaHaven.Services.Implementation "Replace the provisional SH work order number before marking the work order Completed."); } - if (parsed == LifecycleStatus.Scheduled - && !WorkOrderBoardMutationRules.HasConcreteSchedule( - workOrder.ScheduledDate, - workOrder.ScheduleWeekOnly)) - { - throw new WorkOrderBoardValidationException( - "ScheduledRequiresDate", - "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true."); - } + WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed( + workOrder.LifecycleStatus, + parsed.Value, + workOrder.ScheduledDate, + workOrder.ScheduleWeekOnly); var changes = new List(); var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status; diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs new file mode 100644 index 0000000..050b78b --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs @@ -0,0 +1,171 @@ +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; + +/// SH-169 / SH-357 / SH-358: automatic lifecycle states cannot be forged through the board PATCH. +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() + .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 = new WorkOrderBoardUpdateService( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(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 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( + () => 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( + () => 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( + () => 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(ex).Code); + else + Assert.Null(ex); + } +}