shoc-backend/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs
Alexandre Brandizzi c1910e5310 test(work-orders): pin severity-then-type correction from Overdue
The board sends one PATCH per field, severity before workOrderType, so
correcting an Overdue work order to Emergency or Reactive patches the
severity while the stored type is still Overdue. Dropping or rejecting a
severity patch for the current type would leave the corrected Emergency
work order with no severity. Overdue's no-severity rule is enforced when
the type changes, not on the severity patch.
2026-09-25 11:41:33 -03:00

484 lines
18 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
// Overdue is a dispatcher-assigned work-order type: no severity, PM catalog
// (services and completion-doc templates), and distinct from the derived
// past-due overlay.
public class WorkOrderOverdueTypeTests
{
private const int PmServiceId = 10;
private const int ReactiveServiceId = 11;
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static void SeedServices(ApplicationDbContext context)
{
context.Services.AddRange(
NewService(PmServiceId, "Backflow Preventers", WorkOrderType.PM),
NewService(ReactiveServiceId, "Leak Repair", WorkOrderType.Reactive));
}
private static Service NewService(int id, string name, WorkOrderType type) => new()
{
Id = id,
Name = name,
NormalizedName = name.ToUpperInvariant(),
Trade = "Plumbing & Water Systems",
IconKey = "plumbing-water-systems",
IsActive = true,
SupportedWorkOrderTypes = new List<ServiceWorkOrderType>
{
new() { WorkOrderType = type }
}
};
private static WorkOrderBoardCreateService NewCreateService(ApplicationDbContext context)
{
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardCreateService(
boardData,
new WorkOrderBoardMutationDataService(context),
new WorkOrderBoardService(boardData, resolver),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context),
new ServicesRegistryService(new ServicesRegistryDataService(context)));
}
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new ServicesRegistryService(new ServicesRegistryDataService(context)));
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Fact]
public async Task Create_Overdue_WithoutSeverity_PersistsOverdueType()
{
await using var context = NewContext();
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
var persisted = await context.workOrders.SingleAsync();
Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
Assert.Equal(8, (int)persisted.WorkOrderType!.Value);
}
[Fact]
public async Task Create_Overdue_DropsSuppliedSeverity()
{
await using var context = NewContext();
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
Severity = 2
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Null(result.Severity);
Assert.Null((await context.workOrders.SingleAsync()).Severity);
}
[Fact]
public async Task Create_Overdue_AcceptsPmCatalogService()
{
await using var context = NewContext();
SeedServices(context);
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
ServiceId = PmServiceId
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(PmServiceId, result.ServiceId);
Assert.Equal("Backflow Preventers", (await context.workOrders.SingleAsync()).ServiceNameSnapshot);
}
[Fact]
public async Task Create_Overdue_RejectsReactiveOnlyService()
{
await using var context = NewContext();
SeedServices(context);
var service = NewCreateService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
ServiceId = ReactiveServiceId
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ServiceInvalid", ex.Code);
}
[Fact]
public async Task PatchType_ToOverdue_ClearsSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Reactive,
Severity = "3",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
Assert.Null(persisted.Severity);
}
// The board sends one request per field, severity before type, so correcting
// Overdue to Emergency patches severity while the stored type is still Overdue.
// A severity patch must not be dropped or rejected for the current type; the
// no-severity invariant is enforced when the type itself changes.
[Fact]
public async Task PatchSeverityThenType_OverdueToEmergency_KeepsSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var service = NewUpdateService(context);
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "2",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var updated = await context.workOrders.SingleAsync(w => w.Id == 1);
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Emergency",
WorkOrderVersion = ToVersion(updated)
}, "actor-1");
Assert.Equal(WorkOrderType.Emergency, result.WorkOrderType);
Assert.Equal(2, result.Severity);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Emergency, persisted.WorkOrderType);
Assert.Equal("2", persisted.Severity);
}
[Fact]
public async Task PatchType_PmWithPmService_ToOverdue_KeepsService()
{
await using var context = NewContext();
SeedServices(context);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
ServiceId = PmServiceId,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Equal(PmServiceId, (await context.workOrders.SingleAsync(w => w.Id == 1)).ServiceId);
}
[Fact]
public async Task PatchServiceId_OnOverdue_AcceptsPmCatalogService()
{
await using var context = NewContext();
SeedServices(context);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ServiceId,
Value = PmServiceId.ToString(),
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(PmServiceId, persisted.ServiceId);
Assert.Equal("Backflow Preventers", persisted.Trade);
}
[Fact]
public async Task GetDetail_Overdue_ResolvesPmCompletionTemplate()
{
await using var context = NewContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = DateTime.UtcNow.AddDays(1),
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Name = "PM Completion",
ServiceKey = "pm-generic",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/pm.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var service = new WorkOrderDetailService(
new WorkOrderBoardService(boardData, resolver),
new WorkOrderDetailDataService(context),
new CompletionDocTemplateDataService(context),
new UserDataService(context),
resolver);
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(DocStatus.No, detail!.Completion.DocStatus);
Assert.NotNull(detail.Completion.Template);
Assert.Equal("PM Completion", detail.Completion.Template!.Name);
}
[Fact]
public async Task ListServices_Overdue_ReturnsPmCatalog()
{
await using var context = NewContext();
SeedServices(context);
await context.SaveChangesAsync();
var services = await new ServicesRegistryService(new ServicesRegistryDataService(context))
.GetAllAsync(true, WorkOrderType.Overdue, CancellationToken.None);
Assert.Equal(new[] { PmServiceId }, services.Select(s => s.Id).ToArray());
}
[Fact]
public async Task ListTemplates_Overdue_ReturnsPmTemplates()
{
await using var context = NewContext();
context.CompletionDocTemplates.AddRange(
new CompletionDocTemplate
{
Name = "PM Completion",
ServiceKey = "pm-generic",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/pm.pdf",
IsActive = true
},
new CompletionDocTemplate
{
Name = "Reactive Completion",
ServiceKey = "reactive-generic",
WorkOrderType = WorkOrderType.Reactive,
TemplateUrl = "https://example.com/reactive.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
new WorkOrderAuditService(
new WorkOrderAuditDataService(context),
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
WorkOrderAccountTestHelpers.Resolver(context));
var templates = await service.GetTemplatesAsync(null, WorkOrderType.Overdue);
Assert.Equal(new[] { "PM Completion" }, templates.Select(t => t.Name).ToArray());
}
[Fact]
public async Task Search_TypeOverdue_ReturnsOverdueTypeOnly_NotPastDueRows()
{
await using var context = NewContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Overdue }
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id));
Assert.All(result.Items, i => Assert.Equal(WorkOrderType.Overdue, i.WorkOrderType));
}
[Fact]
public async Task Search_TypeOverdueWithPastDue_ReturnsIntersection()
{
await using var context = NewContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Overdue },
Overdue = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
var row = Assert.Single(result.Items);
Assert.Equal(3, row.Id);
Assert.True(row.IsPastDue);
}
[Fact]
public void DashboardBreakdown_CountsByTypeOnly()
{
var today = new DateOnly(2026, 9, 25);
var pastDue = today.AddDays(-3).ToDateTime(TimeOnly.MinValue);
var future = today.AddDays(3).ToDateTime(TimeOnly.MinValue);
var rows = new List<DashboardWorkOrder>
{
new(1, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, future, null, null),
new(2, null, WorkOrderType.PM, LifecycleStatus.Scheduled, null, pastDue, null, null),
new(3, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, pastDue, null, null),
};
var result = DashboardMetrics.Calculate(rows, today);
Assert.Equal(2, result.Breakdown.Overdue);
Assert.Equal(1, result.Breakdown.PM);
Assert.Equal(0, result.Breakdown.Other);
}
private static WorkOrderAdvancedSearchService NewSearchService(ApplicationDbContext context)
=> new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
}