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; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; namespace SeaHavenIndustries.Tests; public class WorkOrderPocServiceTests { [Fact] public async Task ManualOverridePersistsIsAuditedAndLocks() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); await context.SaveChangesAsync(); var service = CreatePocService(context); var row = await service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", "Call first", workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Equal("Manual POC", persisted.PocName); Assert.Equal("312-555-0001", persisted.PocPhone); Assert.Equal("Call first", persisted.PocNotes); Assert.Equal("Manual POC", row.PocName); Assert.Equal("312-555-0001", row.PocPhone); Assert.True(row.PocCustomized); var auditName = await context.WorkOrderAuditLogs.SingleAsync(a => a.WorkOrderId == workOrder.Id && a.FieldName == "PocName"); Assert.Equal("Alice Site", auditName.OldValue); Assert.Equal("Manual POC", auditName.NewValue); Assert.Equal("FieldChanged", auditName.Action); Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.WorkOrderId == workOrder.Id && l.FieldName == "PocName")); Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone")); } [Fact] public async Task ManualOverrideSurvivesCompleteAndLaterSiteChanges() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); workOrder.LifecycleStatus = LifecycleStatus.Scheduled; workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1); await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); await context.SaveChangesAsync(); var service = CreatePocService(context); await service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var updateService = CreateUpdateService(context); var completed = await updateService.PatchFieldAsync( workOrder.Id, Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder), "actor-1"); Assert.Equal("Manual POC", completed.PocName); var siteContact = await context.Contacts.SingleAsync(c => c.FirstName == "Alice"); siteContact.FirstName = "Changed"; siteContact.PhoneNumber = "999-555-0100"; await context.SaveChangesAsync(); var row = await CreateBoardService(context).GetBoardRowAsync( workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); Assert.Equal("Manual POC", row!.PocName); Assert.Equal("312-555-0001", row.PocPhone); } [Fact] public async Task SiteContactUpdateDoesNotOverwriteManualOverride() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); var siteContact = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); await context.SaveChangesAsync(); var service = CreatePocService(context); await service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); siteContact.FirstName = "Rotated"; siteContact.PhoneNumber = "999-555-0100"; await context.SaveChangesAsync(); var row = await CreateBoardService(context).GetBoardRowAsync( workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); Assert.Equal("Manual POC", row!.PocName); Assert.Equal("312-555-0001", row.PocPhone); Assert.True(row.PocCustomized); } [Fact] public async Task UnlockedWorkOrderFollowsSiteContact() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); var primary = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0); await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1); await context.SaveChangesAsync(); var boardService = CreateBoardService(context); var before = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); Assert.Equal("Alice Site", before!.PocName); Assert.Equal("312-555-0100", before.PocPhone); Assert.False(before.PocCustomized); primary.PhoneNumber = "312-555-0999"; await context.SaveChangesAsync(); var after = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); Assert.Equal("Alice Site", after!.PocName); Assert.Equal("312-555-0999", after.PocPhone); Assert.False(after.PocCustomized); } [Fact] public async Task EditEqualToSiteContactDoesNotLock() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); await context.SaveChangesAsync(); var service = CreatePocService(context); await service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var row = await service.UpdatePocAsync( workOrder.Id, Poc("Alice Site", "312-555-0100", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Null(persisted.PocName); Assert.Null(persisted.PocPhone); Assert.Equal("Alice Site", row.PocName); Assert.Equal("312-555-0100", row.PocPhone); Assert.False(row.PocCustomized); var auditName = await context.WorkOrderAuditLogs.Where(a => a.WorkOrderId == workOrder.Id && a.FieldName == "PocName") .OrderByDescending(a => a.Id) .FirstAsync(); Assert.Equal("Manual POC", auditName.OldValue); Assert.Equal("Alice Site", auditName.NewValue); } [Fact] public async Task ClearingBothFieldsFollowsSiteAgain() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); await context.SaveChangesAsync(); var service = CreatePocService(context); await service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", "keep notes", workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var row = await service.UpdatePocAsync( workOrder.Id, Poc(null, null, null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Null(persisted.PocName); Assert.Null(persisted.PocPhone); Assert.Equal("Alice Site", row.PocName); Assert.False(row.PocCustomized); } [Fact] public async Task CrossTenantUpdateIsRejected() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context, accountId: 2); await context.SaveChangesAsync(); var service = CreatePocService(context); var ex = await Assert.ThrowsAsync(() => service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(accountId: 1), "actor-1")); Assert.Equal("NotFound", ex.Code); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Null(persisted.PocName); Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync()); } [Fact] public async Task MissingAccountScopeIsForbidden() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); await context.SaveChangesAsync(); var service = CreatePocService(context); var ex = await Assert.ThrowsAsync(() => service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", null, workOrder), WorkOrderAccountTestHelpers.MissingScope(), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task CompletedWorkOrderIsReadOnly() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); workOrder.LifecycleStatus = LifecycleStatus.Completed; workOrder.CompletedDate = DateTime.UtcNow; await context.SaveChangesAsync(); var service = CreatePocService(context); var ex = await Assert.ThrowsAsync(() => service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Equal("ReadOnly", ex.Code); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Null(persisted.PocName); } [Fact] public async Task CanceledWorkOrderIsReadOnly() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); workOrder.LifecycleStatus = LifecycleStatus.Canceled; await context.SaveChangesAsync(); var service = CreatePocService(context); var ex = await Assert.ThrowsAsync(() => service.UpdatePocAsync( workOrder.Id, Poc("Manual POC", "312-555-0001", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Equal("CanceledReadOnly", ex.Code); } [Fact] public async Task StaleVersionThrowsConflict() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); await context.SaveChangesAsync(); var service = CreatePocService(context); var stale = new WorkOrderPocUpdateRequestDto { PocName = "Manual POC", PocPhone = "312-555-0001", WorkOrderVersion = Convert.ToBase64String(new byte[] { 9, 9, 9, 9 }) }; await Assert.ThrowsAsync(() => service.UpdatePocAsync(workOrder.Id, stale, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Null(persisted.PocName); } [Fact] public async Task CompletionFreezesSiteContactWhenNoOverrideOrLink() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); workOrder.LifecycleStatus = LifecycleStatus.Scheduled; workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1); await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100"); await context.SaveChangesAsync(); var updateService = CreateUpdateService(context); var completed = await updateService.PatchFieldAsync( workOrder.Id, Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder), "actor-1"); Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus); Assert.Equal("Alice Site", completed.PocName); Assert.Equal("312-555-0100", completed.PocPhone); var contact = await context.Contacts.SingleAsync(); contact.PhoneNumber = "999-555-0100"; await context.SaveChangesAsync(); var row = await CreateBoardService(context).GetBoardRowAsync( workOrder.Id, WorkOrderAccountTestHelpers.AccountUser()); Assert.Equal("Alice Site", row!.PocName); Assert.Equal("312-555-0100", row.PocPhone); } [Fact] public async Task BoardCreateWithPocEqualToSiteContactFollowsSite() { await using var context = CreateContext(); WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); context.Contacts.Add(new Contacts { FirstName = "Alice", LastName = "Site", PhoneNumber = "312-555-0100", LocationId = 1, SiteContactOrder = 0 }); await context.SaveChangesAsync(); var service = CreateCreateService(context); var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", LocationId = 1, PocName = "Alice Site", PocPhone = "312-555-0100" }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); Assert.Null(persisted.PocName); Assert.Null(persisted.PocPhone); Assert.Equal("Alice Site", row.PocName); Assert.False(row.PocCustomized); } [Fact] public async Task BoardCreateWithPocAwayFromSiteContactIsStored() { await using var context = CreateContext(); WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); context.Contacts.Add(new Contacts { FirstName = "Alice", LastName = "Site", PhoneNumber = "312-555-0100", LocationId = 1, SiteContactOrder = 0 }); await context.SaveChangesAsync(); var service = CreateCreateService(context); var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", LocationId = 1, PocName = "Manual POC", PocPhone = "312-555-0001" }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); Assert.Equal("Manual POC", persisted.PocName); Assert.Equal("312-555-0001", persisted.PocPhone); Assert.True(row.PocCustomized); } [Fact] public async Task EditToSecondSiteContactStoresOverride() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0); await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1); await context.SaveChangesAsync(); // The WO follows the Site primary (Alice). Editing to the second live Site // contact (Bob) is a change away from what the board shows and must be // stored and locked, not silently cleared back to Alice. var service = CreatePocService(context); var row = await service.UpdatePocAsync( workOrder.Id, Poc("Bob Backup", "312-555-0200", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Equal("Bob Backup", persisted.PocName); Assert.Equal("312-555-0200", persisted.PocPhone); Assert.Equal("Bob Backup", row.PocName); Assert.Equal("312-555-0200", row.PocPhone); Assert.True(row.PocCustomized); var auditName = await context.WorkOrderAuditLogs.SingleAsync(a => a.WorkOrderId == workOrder.Id && a.FieldName == "PocName"); Assert.Equal("Alice Site", auditName.OldValue); Assert.Equal("Bob Backup", auditName.NewValue); Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.WorkOrderId == workOrder.Id && l.FieldName == "PocName")); Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone")); } [Fact] public async Task EditToSitePrimaryWhenLinkedContactDiffersStoresOverride() { await using var context = CreateContext(); await SeedScopeAsync(context); var workOrder = NewWorkOrder(context); await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0); var linked = new Contacts { FirstName = "Carol", LastName = "Linked", PhoneNumber = "312-555-0300" }; context.Contacts.Add(linked); await context.SaveChangesAsync(); context.WorkOrderContacts.Add(new WorkOrderContacts { WorkorderId = workOrder.Id, ContactId = linked.Id }); await context.SaveChangesAsync(); // The WO follows its linked contact (Carol). Typing the Site's primary // (Alice) is a change away from what the board shows and must be stored, // not cleared back to Carol. var service = CreatePocService(context); var row = await service.UpdatePocAsync( workOrder.Id, Poc("Alice Site", "312-555-0100", null, workOrder), WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id); Assert.Equal("Alice Site", persisted.PocName); Assert.Equal("312-555-0100", persisted.PocPhone); Assert.Equal("Alice Site", row.PocName); Assert.Equal("312-555-0100", row.PocPhone); Assert.True(row.PocCustomized); var auditName = await context.WorkOrderAuditLogs.SingleAsync(a => a.WorkOrderId == workOrder.Id && a.FieldName == "PocName"); Assert.Equal("Carol Linked", auditName.OldValue); Assert.Equal("Alice Site", auditName.NewValue); } [Fact] public async Task BoardCreateWithPocEqualToSecondSiteContactIsStored() { await using var context = CreateContext(); WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); context.Contacts.Add(new Contacts { FirstName = "Alice", LastName = "Site", PhoneNumber = "312-555-0100", LocationId = 1, SiteContactOrder = 0 }); context.Contacts.Add(new Contacts { FirstName = "Bob", LastName = "Backup", PhoneNumber = "312-555-0200", LocationId = 1, SiteContactOrder = 1 }); await context.SaveChangesAsync(); // Create-time POC equal to the second Site contact (Bob) is not the contact // the WO would follow (the primary, Alice), so it must be stored. var service = CreateCreateService(context); var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", LocationId = 1, PocName = "Bob Backup", PocPhone = "312-555-0200" }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); Assert.Equal("Bob Backup", persisted.PocName); Assert.Equal("312-555-0200", persisted.PocPhone); Assert.True(row.PocCustomized); } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static async Task SeedScopeAsync(ApplicationDbContext context) { WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); await context.SaveChangesAsync(); } private static WorkOrder NewWorkOrder(ApplicationDbContext context, int accountId = 1) { var location = context.Locations.Local.Single(l => l.Id == 1); var workOrder = new WorkOrder { Id = 1, InternalWONumber = "00000012345", SiteCode = "BK5", LocationId = location.Id, Locations = location, AccountId = accountId, LifecycleStatus = LifecycleStatus.Incomplete, RowVersion = Version() }; context.workOrders.Add(workOrder); return workOrder; } private static async Task SeedSiteContactAsync( ApplicationDbContext context, string firstName, string lastName, string phone, int order = 0) { var contact = new Contacts { FirstName = firstName, LastName = lastName, PhoneNumber = phone, LocationId = 1, SiteContactOrder = order }; context.Contacts.Add(contact); await context.SaveChangesAsync(); return contact; } private static WorkOrderPocService CreatePocService(ApplicationDbContext context) => new( new WorkOrderPocDataService(context), new WorkOrderBoardMutationDataService(context), CreateBoardService(context), new WorkOrderAuditService( new WorkOrderAuditDataService(context), new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))), WorkOrderAccountTestHelpers.Resolver(context)); private static WorkOrderBoardService CreateBoardService(ApplicationDbContext context) => new( new WorkOrderBoardDataService(context), WorkOrderAccountTestHelpers.Resolver(context)); private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); return new WorkOrderBoardUpdateService( new WorkOrderBoardDataService(context), new WorkOrderBoardMutationDataService(context), new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); } private static WorkOrderBoardCreateService CreateCreateService(ApplicationDbContext context) => new( new WorkOrderBoardDataService(context), new WorkOrderBoardMutationDataService(context), CreateBoardService(context), new WorkOrderAuditService( new WorkOrderAuditDataService(context), new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))), new WorkOrderBoardCreateValidation(), WorkOrderAccountTestHelpers.Resolver(context), new WorkOrderPocDataService(context)); private static WorkOrderPocUpdateRequestDto Poc( string? name, string? phone, string? notes, WorkOrder workOrder) => new() { PocName = name, PocPhone = phone, PocNotes = notes, WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version()) }; private static WorkOrderBoardPatchRequestDto Patch( string field, string value, WorkOrder workOrder) => new() { Field = field, Value = value, WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version()) }; private static byte[] Version() => new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; }