diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs new file mode 100644 index 0000000..343337e --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -0,0 +1,405 @@ +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; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderDocStatusPatchTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderBoardUpdateService(context, boardData, audit); + return (context, service); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + [Theory] + [InlineData("Yes", DocStatus.Yes)] + [InlineData("No", DocStatus.No)] + [InlineData("NotNeeded", DocStatus.NotNeeded)] + public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected) + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DocStatus, + Value = value, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(expected, result.DocStatus); + var audit = await context.WorkOrderAuditLogs.SingleAsync(); + Assert.Equal("DocStatus", audit.FieldName); + } + + [Fact] + public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DocStatus, + Value = "Yes", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } +} + +public class WorkOrderDetailServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var detailData = new WorkOrderDetailDataService(context); + var templateData = new CompletionDocTemplateDataService(context); + var service = new WorkOrderDetailService(boardService, detailData, templateData); + return (context, service); + } + + [Fact] + public async Task GetDetail_ReturnsDerivedFieldsAndFeAuditMapping() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "00000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(-2), + Trade = "HVAC PM", + WorkOrderType = WorkOrderType.PM, + DocStatus = DocStatus.No, + Description = "Leak", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + context.CompletionDocTemplates.Add(new CompletionDocTemplate + { + Name = "HVAC PM Completion", + ServiceKey = "HVAC PM", + WorkOrderType = WorkOrderType.PM, + TemplateUrl = "https://example.com/hvac.pdf", + IsActive = true + }); + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = 1, + EventType = "System", + Action = "WeekRolled", + FieldName = "CarriedOver", + OldValue = "0", + NewValue = "1", + CreatedAt = DateTime.UtcNow + }); + context.Comments.Add(new Comments + { + WorkerOrderId = 1, + UserId = "user-1", + Commenttext = "Called vendor", + CreatedDate = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc) + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1); + + Assert.NotNull(detail); + Assert.True(detail!.Info.IsPastDue); + Assert.Equal("HVAC PM", detail.Info.Trade); + Assert.Equal("Leak", detail.Info.Description); + Assert.NotNull(detail.Completion.Template); + Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name); + Assert.Single(detail.Comments); + Assert.Equal("user-1", detail.Comments[0].AuthorId); + Assert.Equal("Called vendor", detail.Comments[0].Text); + Assert.Single(detail.Audit); + Assert.Equal("system", detail.Audit[0].Type); + Assert.Equal("WeekRolled", detail.Audit[0].Action); + } +} + +public class WorkOrderCompletionServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var templateData = new CompletionDocTemplateDataService(context); + var detailData = new WorkOrderDetailDataService(context); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderCompletionService(context, templateData, detailData, audit); + return (context, service); + } + + [Fact] + public async Task UploadCompletionDoc_SetsDocStatusYesAndSignOff() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + Trade = "HVAC PM", + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + SignOffName = "Jane Dispatcher", + WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!) + }, + "https://example.com/signed.pdf", + "actor-1"); + + Assert.Equal(DocStatus.Yes, result.DocStatus); + Assert.Equal("https://example.com/signed.pdf", result.SignOffAttachment); + Assert.Equal("Jane Dispatcher", result.SignOffName); + + var updated = await context.workOrders.FindAsync(1); + Assert.Equal(DocStatus.Yes, updated!.DocStatus); + Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync()); + } + + [Fact] + public async Task UploadCompletionDoc_SecondUpload_DoesNotDuplicateFieldLocks() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var version = Convert.ToBase64String(wo.RowVersion!); + await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version }, + "https://example.com/first.pdf", + "actor-1"); + + // Simulate a new request scope: clear local tracker then reload + context.ChangeTracker.Clear(); + var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1); + // InMemory does not bump rowversion; keep same bytes for the second call + reloaded.RowVersion ??= new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + + await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!) + }, + "https://example.com/second.pdf", + "actor-1"); + + var locks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Equal(1, locks.Count(l => l.FieldName == "SignOffAttachment")); + Assert.Equal(1, locks.Count(l => l.FieldName == "DocStatus")); + Assert.Equal("https://example.com/second.pdf", reloaded.SignOffAttachment); + } + + [Fact] + public async Task UploadCompletionDoc_MissingVersion_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto(), + "https://example.com/signed.pdf", + "actor-1")); + + Assert.Equal("WorkOrderVersionRequired", ex.Code); + } + + [Fact] + public async Task UploadCompletionDoc_ReadOnly_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, + "https://example.com/signed.pdf", + "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } +} + +public class WorkOrderCommentServiceTests +{ + [Fact] + public async Task AddComment_ReturnsFeSchema() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled }); + await context.SaveChangesAsync(); + + var detailData = new WorkOrderDetailDataService(context); + var commentData = new CommentDataService(context); + var service = new WorkOrderCommentService(detailData, commentData); + + var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc"); + + Assert.Equal("user-abc", result.AuthorId); + Assert.Equal("Note", result.Text); + Assert.False(string.IsNullOrWhiteSpace(result.Time)); + } +} + +public class WorkOrderMediaServiceTests +{ + [Fact] + public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + BeforPhotoAttachment = "https://example.com/before.jpg", + AfterPhotoAttachment = "https://example.com/after.jpg" + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var detailData = new WorkOrderDetailDataService(context); + var service = new WorkOrderMediaService(context, detailData); + var media = await service.GetMediaAsync(1); + + Assert.NotNull(media); + Assert.Equal(3, media!.Count); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Before); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.After); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra); + } + + [Fact] + public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context)); + var ex = await Assert.ThrowsAsync(() => + service.DeleteMediaAsync(1, 10, "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } + + [Fact] + public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context)); + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1")); + + Assert.Equal("UseCompletionDocEndpoint", ex.Code); + } +}