mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Merge branch 'dev' into fix/ab/sh-370-media-undetermined-mime
This commit is contained in:
commit
36d76a246d
3 changed files with 228 additions and 9 deletions
|
|
@ -0,0 +1,52 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 (<see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>),
|
||||
/// not a <see cref="LifecycleStatus"/> value, so it cannot be requested at all.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FieldChange>();
|
||||
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue