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