diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs new file mode 100644 index 0000000..d3c0764 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -0,0 +1,241 @@ +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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + var payload = new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-100", + Description = "Initial", + WoStatus = "new", + Severity = "2", + SiteCode = "BK1" + }; + + 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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var createService = new WorkOrderBoardCreateService( + context, boardData, boardService, audit, new WorkOrderBoardCreateValidation()); + var policy = new SyncFieldMergePolicy(fieldLocks); + var ingest = new WorkOrderIngestService(context, policy, fieldLocks, audit); + + await createService.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "dispatcher-1"); + + 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() + .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(); + + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + 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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + var result = await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" }, + new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" } + }); + + Assert.Equal(1, result.Created); + Assert.Equal(1, result.Skipped); + Assert.Equal(1, result.Results.Count); + Assert.Equal("ext-ok", result.Results[0].ExternalWorkOrderId); + } + + [Fact] + public async Task OpsHealth_ReturnsCoexistenceMetrics() + { + var options = new DbContextOptionsBuilder() + .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" + }); + + var service = new WorkOrderOpsHealthService(context, runState, healthOptions); + 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() + .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); + var boardService = new WorkOrderBoardService(boardData); + var count = await context.workOrders.CountAsync(); + + Assert.Equal(1, count); + Assert.NotNull(boardService); + } +}