2026-07-13 13:23:07 -03:00
|
|
|
using Data.SeaHavenIndustries;
|
|
|
|
|
using Data.SeaHavenIndustries.Enums;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
using SeaHaven.DataServices.Implementation;
|
|
|
|
|
using SeaHaven.Services.DTOs;
|
|
|
|
|
using SeaHaven.Services.Helpers;
|
|
|
|
|
using SeaHaven.Services.Implementation;
|
|
|
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
|
using SeaHaven.Services.Validation;
|
|
|
|
|
|
|
|
|
|
namespace SeaHavenIndustries.Tests;
|
|
|
|
|
|
|
|
|
|
public class WorkOrderPhase7CoexistenceTests
|
|
|
|
|
{
|
|
|
|
|
[Fact]
|
|
|
|
|
public void IngestFieldMapper_MapsDynamoStatus()
|
|
|
|
|
{
|
|
|
|
|
Assert.Equal("Open", WorkOrderIngestFieldMapper.MapStatus("new"));
|
|
|
|
|
Assert.Equal("In Progress", WorkOrderIngestFieldMapper.MapStatus("in_progress"));
|
|
|
|
|
Assert.Equal("Done", WorkOrderIngestFieldMapper.MapStatus("completed"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task IngestService_CreatesThenUpdates_IdempotentByExternalId()
|
|
|
|
|
{
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
|
|
|
.Options;
|
|
|
|
|
await using var context = new ApplicationDbContext(options);
|
2026-08-11 10:45:37 -03:00
|
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
|
2026-07-13 13:23:07 -03:00
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
|
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
2026-08-11 10:45:37 -03:00
|
|
|
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
2026-07-13 13:23:07 -03:00
|
|
|
|
|
|
|
|
var payload = new WorkOrderIngestPayloadDto
|
|
|
|
|
{
|
|
|
|
|
ExternalWorkOrderId = "ext-100",
|
|
|
|
|
Description = "Initial",
|
|
|
|
|
WoStatus = "new",
|
|
|
|
|
Severity = "2",
|
2026-08-11 10:45:37 -03:00
|
|
|
SiteCode = "BK1",
|
|
|
|
|
Customer = "Phase7 Customer"
|
2026-07-13 13:23:07 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var create = await ingest.UpsertBatchAsync(new[] { payload });
|
|
|
|
|
Assert.Equal(1, create.Created);
|
|
|
|
|
Assert.Equal(0, create.Updated);
|
|
|
|
|
|
|
|
|
|
payload.Description = "Updated via ingest";
|
|
|
|
|
var update = await ingest.UpsertBatchAsync(new[] { payload });
|
|
|
|
|
Assert.Equal(0, update.Created);
|
|
|
|
|
Assert.Equal(1, update.Updated);
|
|
|
|
|
|
|
|
|
|
var wo = await context.workOrders.SingleAsync();
|
|
|
|
|
Assert.Equal("Updated via ingest", wo.Description);
|
|
|
|
|
Assert.Equal("ext-100", wo.ExternalWorkOrderId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task IngestRespectsFieldLock_AfterShocBoardCreate()
|
|
|
|
|
{
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
|
|
|
.Options;
|
|
|
|
|
await using var context = new ApplicationDbContext(options);
|
2026-08-26 09:12:48 -03:00
|
|
|
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
2026-07-13 13:23:07 -03:00
|
|
|
|
|
|
|
|
var boardData = new WorkOrderBoardDataService(context);
|
2026-07-24 17:35:34 -03:00
|
|
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
2026-08-11 10:45:37 -03:00
|
|
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
|
|
|
|
var boardService = new WorkOrderBoardService(boardData, resolver);
|
2026-07-24 17:35:34 -03:00
|
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
|
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
2026-07-13 13:23:07 -03:00
|
|
|
var createService = new WorkOrderBoardCreateService(
|
2026-09-18 14:30:54 -03:00
|
|
|
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
2026-07-13 13:23:07 -03:00
|
|
|
var policy = new SyncFieldMergePolicy(fieldLocks);
|
2026-08-11 10:45:37 -03:00
|
|
|
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
2026-07-13 13:23:07 -03:00
|
|
|
|
|
|
|
|
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
|
|
|
|
{
|
|
|
|
|
WorkOrderType = WorkOrderType.PM,
|
2026-08-26 09:12:48 -03:00
|
|
|
SiteCode = "BK5",
|
|
|
|
|
LocationId = 1
|
2026-08-11 10:45:37 -03:00
|
|
|
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
2026-07-13 13:23:07 -03:00
|
|
|
|
|
|
|
|
var wo = await context.workOrders.SingleAsync();
|
|
|
|
|
wo.ExternalWorkOrderId = "ext-shoc-1";
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
await ingest.UpsertBatchAsync(new[]
|
|
|
|
|
{
|
|
|
|
|
new WorkOrderIngestPayloadDto
|
|
|
|
|
{
|
|
|
|
|
ExternalWorkOrderId = "ext-shoc-1",
|
|
|
|
|
SiteCode = "SYNC-SITE"
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Assert.Equal("BK5", wo.SiteCode);
|
|
|
|
|
Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => l.Action == "SyncRejected"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task IngestRespectsLocationIdFieldLock()
|
|
|
|
|
{
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
|
|
|
.Options;
|
|
|
|
|
await using var context = new ApplicationDbContext(options);
|
|
|
|
|
|
|
|
|
|
context.Locations.Add(new Locations { Id = 1, Name = "BK1", Status = "Active" });
|
|
|
|
|
context.Locations.Add(new Locations { Id = 2, Name = "BK2", Status = "Active" });
|
|
|
|
|
context.workOrders.Add(new WorkOrder
|
|
|
|
|
{
|
|
|
|
|
Id = 10,
|
|
|
|
|
ExternalWorkOrderId = "ext-loc-1",
|
|
|
|
|
LocationId = 1,
|
|
|
|
|
SiteCode = "BK1",
|
|
|
|
|
istemplate = false
|
|
|
|
|
});
|
|
|
|
|
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
|
|
|
|
{
|
|
|
|
|
WorkOrderId = 10,
|
|
|
|
|
FieldName = "LocationId",
|
|
|
|
|
LockedAt = DateTime.UtcNow,
|
|
|
|
|
LockedByUserId = "dispatcher-1"
|
|
|
|
|
});
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
|
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
2026-08-11 10:45:37 -03:00
|
|
|
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
2026-07-13 13:23:07 -03:00
|
|
|
|
|
|
|
|
await ingest.UpsertBatchAsync(new[]
|
|
|
|
|
{
|
|
|
|
|
new WorkOrderIngestPayloadDto
|
|
|
|
|
{
|
|
|
|
|
ExternalWorkOrderId = "ext-loc-1",
|
|
|
|
|
SiteCode = "BK2",
|
|
|
|
|
Building = "BK2"
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var wo = await context.workOrders.SingleAsync(w => w.Id == 10);
|
|
|
|
|
Assert.Equal(1, wo.LocationId);
|
|
|
|
|
Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l =>
|
|
|
|
|
l.Action == "SyncRejected" && l.FieldName == "LocationId"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task IngestBatch_SkipsItemsWithoutExternalId()
|
|
|
|
|
{
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
|
|
|
.Options;
|
|
|
|
|
await using var context = new ApplicationDbContext(options);
|
2026-08-11 10:45:37 -03:00
|
|
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
|
2026-07-13 13:23:07 -03:00
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
|
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
2026-08-11 10:45:37 -03:00
|
|
|
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
2026-07-13 13:23:07 -03:00
|
|
|
|
|
|
|
|
var result = await ingest.UpsertBatchAsync(new[]
|
|
|
|
|
{
|
|
|
|
|
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" },
|
2026-08-11 10:45:37 -03:00
|
|
|
new WorkOrderIngestPayloadDto
|
|
|
|
|
{
|
|
|
|
|
ExternalWorkOrderId = "ext-ok",
|
|
|
|
|
Description = "good",
|
|
|
|
|
Customer = "Phase7 Customer"
|
|
|
|
|
}
|
2026-07-13 13:23:07 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Assert.Equal(1, result.Created);
|
|
|
|
|
Assert.Equal(1, result.Skipped);
|
2026-07-24 17:35:34 -03:00
|
|
|
var createdResult = Assert.Single(result.Results);
|
|
|
|
|
Assert.Equal("ext-ok", createdResult.ExternalWorkOrderId);
|
2026-07-13 13:23:07 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task OpsHealth_ReturnsCoexistenceMetrics()
|
|
|
|
|
{
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
|
|
|
.Options;
|
|
|
|
|
await using var context = new ApplicationDbContext(options);
|
|
|
|
|
|
|
|
|
|
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
|
|
|
|
{
|
|
|
|
|
WorkOrderId = 1,
|
|
|
|
|
FieldName = "SiteCode",
|
|
|
|
|
LockedAt = DateTime.UtcNow,
|
|
|
|
|
LockedByUserId = "u1"
|
|
|
|
|
});
|
|
|
|
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
|
|
|
{
|
|
|
|
|
WorkOrderId = 1,
|
|
|
|
|
Action = "SyncRejected",
|
|
|
|
|
FieldName = "Description",
|
|
|
|
|
CreatedAt = DateTime.UtcNow.AddHours(-1)
|
|
|
|
|
});
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
var runState = new WorkOrderJobRunStateAccessor
|
|
|
|
|
{
|
|
|
|
|
State = { LastWeekRolledRunUtc = DateTime.UtcNow.AddDays(-1) }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var healthOptions = Options.Create(new WorkOrderOpsHealthOptions
|
|
|
|
|
{
|
|
|
|
|
SyncEnabled = true,
|
|
|
|
|
IngestEnabled = true,
|
|
|
|
|
LegacyDeprecationEnabled = true,
|
|
|
|
|
LegacySunsetDate = "2026-12-31"
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
var service = new WorkOrderOpsHealthService(new WorkOrderOpsHealthDataService(context), runState, healthOptions);
|
2026-07-13 13:23:07 -03:00
|
|
|
var health = await service.GetHealthAsync();
|
|
|
|
|
|
|
|
|
|
Assert.Equal(1, health.FieldLockCount);
|
|
|
|
|
Assert.Equal(1, health.SyncRejectedLast24h);
|
|
|
|
|
Assert.True(health.SyncEnabled);
|
|
|
|
|
Assert.True(health.IngestEnabled);
|
|
|
|
|
Assert.Equal("2026-12-31", health.LegacySunsetDate);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task BoardAndLegacyEndpoints_CanCoexist_InSameDatabase()
|
|
|
|
|
{
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
|
|
|
.Options;
|
|
|
|
|
await using var context = new ApplicationDbContext(options);
|
|
|
|
|
|
|
|
|
|
context.workOrders.Add(new WorkOrder
|
|
|
|
|
{
|
|
|
|
|
WorkerOrderTitle = "Legacy row",
|
|
|
|
|
Status = "Open",
|
|
|
|
|
InternalWONumber = "10000001",
|
|
|
|
|
istemplate = false
|
|
|
|
|
});
|
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
var boardData = new WorkOrderBoardDataService(context);
|
2026-08-11 10:45:37 -03:00
|
|
|
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
2026-07-13 13:23:07 -03:00
|
|
|
var count = await context.workOrders.CountAsync();
|
|
|
|
|
|
|
|
|
|
Assert.Equal(1, count);
|
|
|
|
|
Assert.NotNull(boardService);
|
|
|
|
|
}
|
|
|
|
|
}
|