shoc-backend/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
Arthur Bassi c9a80f5c79 fix(work-orders): restore SH-185 Schedule On Past Due and assert IsAddOn audit
Past Due follows ScheduledDate so Due Date alone cannot set or clear it. Auto-schedule and reschedule tests now expect the third IsAddOn audit field.
2026-08-17 10:36:03 -03:00

352 lines
12 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class WorkOrderWeekRolledTests
{
private static readonly DateOnly SourceWeekStart = new(2026, 6, 16);
private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20);
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context)
{
var dataService = new WorkOrderDomainJobDataService(context);
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)));
return new WorkOrderWeekRolledService(
dataService,
audit,
NullLogger<WorkOrderWeekRolledService>.Instance);
}
[Fact]
public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(1, result.Processed);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_SkipsTerminalStatuses()
{
await using var context = CreateContext();
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Completed
},
new WorkOrder
{
Id = 2,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Canceled
});
await context.SaveChangesAsync();
var service = CreateService(context);
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(0, result.Processed);
Assert.Equal(0, result.Failed);
}
[Fact]
public async Task WeekRolled_Idempotent_SecondRunSkips()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
var first = await service.ProcessWeekRolledAsync(SourceWeekStart);
var second = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(1, first.Processed);
Assert.Equal(0, second.Processed);
Assert.Equal(0, second.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_WritesAuditEvent()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.InProgress,
CarriedOver = 2
});
await context.SaveChangesAsync();
var service = CreateService(context);
await service.ProcessWeekRolledAsync(SourceWeekStart);
var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Single(logs);
Assert.Equal("WeekRolled", logs[0].Action);
Assert.Equal("System", logs[0].EventType);
Assert.Equal("System", logs[0].ActorType);
Assert.Equal("CarriedOver", logs[0].FieldName);
Assert.Equal("2", logs[0].OldValue);
Assert.Equal("3", logs[0].NewValue);
Assert.Equal("week:2026-06-16", logs[0].CorrelationId);
var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Empty(locks);
}
[Fact]
public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
Assert.True(first.Processed);
Assert.True(second.Skipped);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
var service = CreateService(context);
var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(0, rerun.Processed);
Assert.Equal(0, rerun.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_ProcessesAllEligible_WhenMoreThanBatchSize()
{
// WorkOrderWeekRolledService.BatchSize is 500; more than one page must all be carried over.
const int eligibleCount = 501;
await using var context = CreateContext();
for (var i = 1; i <= eligibleCount; i++)
{
context.workOrders.Add(new WorkOrder
{
Id = i,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
}
await context.SaveChangesAsync();
var service = CreateService(context);
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(eligibleCount, result.Processed);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Failed);
var carriedOverCount = await context.workOrders.CountAsync(w => w.CarriedOver == 1);
Assert.Equal(eligibleCount, carriedOverCount);
var ledgerCount = await context.WorkOrderWeekRolledLedgers.CountAsync();
Assert.Equal(eligibleCount, ledgerCount);
var auditCount = await context.WorkOrderAuditLogs.CountAsync(l => l.Action == "WeekRolled");
Assert.Equal(eligibleCount, auditCount);
}
[Fact]
public async Task PastDueCache_SyncsOperationalFlags()
{
await using var context = CreateContext();
var pastDate = DateTime.UtcNow.Date.AddDays(-3);
var futureDate = DateTime.UtcNow.Date.AddDays(3);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
DueDate = pastDate,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 2,
DueDate = pastDate,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Completed,
OperationalFlags = OperationalFlags.PastDue
},
new WorkOrder
{
Id = 3,
DueDate = futureDate,
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var result = await dataService.RefreshPastDueFlagsAsync(DateTime.UtcNow);
Assert.Equal(1, result.SetCount);
Assert.Equal(2, result.ClearedCount);
var wo1 = await context.workOrders.FindAsync(1);
var wo2 = await context.workOrders.FindAsync(2);
var wo3 = await context.workOrders.FindAsync(3);
Assert.True(wo1!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
Assert.False(wo2!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
Assert.False(wo3!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
}
[Fact]
public async Task Board_ReflectsCarriedOver_AfterWeekRolled()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
await service.ProcessWeekRolledAsync(SourceWeekStart);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row);
Assert.Equal(1, row!.CarriedOver);
}
[Fact]
public async Task Board_IsPastDue_UnaffectedByCache()
{
await using var context = CreateContext();
var pastDate = DateTime.UtcNow.Date.AddDays(-2);
context.workOrders.Add(new WorkOrder
{
Id = 1,
DueDate = DateTime.UtcNow.Date.AddDays(5),
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.None
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row);
Assert.True(row!.IsPastDue);
Assert.True(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled));
}
[Fact]
public async Task Board_IsPastDue_IgnoresStalePastDueFlag()
{
await using var context = CreateContext();
var futureDate = DateTime.UtcNow.Date.AddDays(5);
context.workOrders.Add(new WorkOrder
{
Id = 1,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row);
Assert.False(row!.IsPastDue);
}
}