mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Incomplete and Scheduled are derived by the server. A direct lifecycleStatus PATCH may only restate the status derivation already produced; any other request to move a work order into an automatic state returns the stable AutomaticLifecycleStatus validation error and leaves status and audit untouched.
171 lines
7 KiB
C#
171 lines
7 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 = 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<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);
|
|
}
|
|
}
|