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 mutationData = new WorkOrderBoardMutationDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var service = new WorkOrderBoardUpdateService(boardData, mutationData, 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 userData = new UserDataService(context); var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); 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.Users.Add(new ApplicationUser { Id = "user-1", UserName = "alice", FirstName = "Alice", LastName = "Dispatcher" }); 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("Alice Dispatcher", detail.Comments[0].AuthorName); 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); } [Fact] public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames() { var (context, service) = CreateSut(); const string oldUserId = "a1000001-0001-4000-8000-000000000002"; const string newUserId = "b5e356d5-d926-4f92-8673-ee13cddeff0f"; context.Users.AddRange( new ApplicationUser { Id = oldUserId, UserName = "alice", FirstName = "Alice", LastName = "Dispatcher" }, new ApplicationUser { Id = newUserId, UserName = "bob", FirstName = "Bob", LastName = "Tech" }); context.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "00000000001", LifecycleStatus = LifecycleStatus.Pending, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = 1, UserId = "actor-1", EventType = "Manual", Action = "AssignmentChanged", FieldName = "AssignTo", OldValue = oldUserId, NewValue = newUserId, CreatedAt = DateTime.UtcNow }); context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = 1, UserId = "actor-1", EventType = "Manual", Action = "AssignmentChanged", FieldName = "AssignTo", OldValue = newUserId, NewValue = "", CreatedAt = DateTime.UtcNow.AddMinutes(1) }); await context.SaveChangesAsync(); var audit = await service.GetAuditAsync(1); Assert.NotNull(audit); Assert.Equal(2, audit!.Count); var unassigned = audit.Single(a => a.NewValue == "Unassigned"); Assert.Equal("Bob Tech", unassigned.OldValue); var reassigned = audit.Single(a => a.NewValue == "Bob Tech"); Assert.Equal("Alice Dispatcher", reassigned.OldValue); } } 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 completionData = new WorkOrderCompletionDataService(context); var detailData = new WorkOrderDetailDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var service = new WorkOrderCompletionService(completionData, 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 }); context.Users.Add(new ApplicationUser { Id = "user-abc", UserName = "bob", FirstName = "Bob", LastName = "Tech" }); await context.SaveChangesAsync(); var detailData = new WorkOrderDetailDataService(context); var commentData = new CommentDataService(context); var userData = new UserDataService(context); var service = new WorkOrderCommentService(detailData, commentData, userData); var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc"); Assert.Equal("user-abc", result.AuthorId); Assert.Equal("Bob Tech", result.AuthorName); Assert.Equal("Note", result.Text); Assert.False(string.IsNullOrWhiteSpace(result.Time)); } [Fact] public async Task GetComments_FallsBackToCommenterWhenUserIdMissing() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var context = new ApplicationDbContext(options); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled }); context.Comments.Add(new Comments { WorkerOrderId = 1, Commenter = "Legacy Sync Author", Commenttext = "Synced note", CreatedDate = DateTime.UtcNow }); await context.SaveChangesAsync(); var detailData = new WorkOrderDetailDataService(context); var commentData = new CommentDataService(context); var userData = new UserDataService(context); var service = new WorkOrderCommentService(detailData, commentData, userData); var comments = await service.GetCommentsAsync(1); Assert.NotNull(comments); Assert.Single(comments!); Assert.Null(comments[0].AuthorId); Assert.Equal("Legacy Sync Author", comments[0].AuthorName); Assert.Equal("Synced note", comments[0].Text); } private static async Task<(ApplicationDbContext Context, WorkOrderCommentService Service, Comments Comment)> SeedEditableCommentAsync( LifecycleStatus status = LifecycleStatus.Scheduled, string authorId = "user-author", string commentType = "General", string recordType = "WorkOrder", int workOrderId = 1) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var context = new ApplicationDbContext(options); context.workOrders.Add(new WorkOrder { Id = workOrderId, LifecycleStatus = status }); context.Users.Add(new ApplicationUser { Id = authorId, UserName = "author", FirstName = "Alice", LastName = "Dispatcher" }); var comment = new Comments { WorkerOrderId = workOrderId, UserId = authorId, Commenttext = "Original", CommentType = commentType, RecordType = recordType, CreatedDate = DateTime.UtcNow }; context.Comments.Add(comment); await context.SaveChangesAsync(); var service = new WorkOrderCommentService( new WorkOrderDetailDataService(context), new CommentDataService(context), new UserDataService(context)); return (context, service, comment); } [Fact] public async Task UpdateComment_Author_UpdatesText() { var (_, service, comment) = await SeedEditableCommentAsync(); var result = await service.UpdateCommentAsync( 1, comment.Id, new WorkOrderCommentCreateDto { Text = " Updated note " }, "user-author"); Assert.Equal("Updated note", result.Text); Assert.Equal("user-author", result.AuthorId); Assert.Equal("Alice Dispatcher", result.AuthorName); } [Fact] public async Task UpdateComment_AdminNonAuthor_ThrowsForbidden() { var (_, service, comment) = await SeedEditableCommentAsync(); var ex = await Assert.ThrowsAsync(() => service.UpdateCommentAsync( 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Admin edit" }, "admin-user")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task UpdateComment_NonAuthor_ThrowsForbidden() { var (_, service, comment) = await SeedEditableCommentAsync(); var ex = await Assert.ThrowsAsync(() => service.UpdateCommentAsync( 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, "other-user")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task UpdateComment_LegacyVendorComment_ThrowsNotEditable() { var (_, service, comment) = await SeedEditableCommentAsync( commentType: "vendor", recordType: "WorkOrder"); var ex = await Assert.ThrowsAsync(() => service.UpdateCommentAsync( 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, "user-author")); Assert.Equal("NotEditable", ex.Code); } [Fact] public async Task UpdateComment_ReadOnlyWorkOrder_Throws() { var (_, service, comment) = await SeedEditableCommentAsync(LifecycleStatus.Canceled); var ex = await Assert.ThrowsAsync(() => service.UpdateCommentAsync( 1, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, "user-author")); Assert.Equal("ReadOnly", ex.Code); } [Fact] public async Task UpdateComment_WrongWorkOrder_ThrowsNotFound() { var (context, service, comment) = await SeedEditableCommentAsync(workOrderId: 1); context.workOrders.Add(new WorkOrder { Id = 2, LifecycleStatus = LifecycleStatus.Scheduled }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.UpdateCommentAsync( 2, comment.Id, new WorkOrderCommentCreateDto { Text = "Nope" }, "user-author")); Assert.Equal("NotFound", ex.Code); } } 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 mediaData = new WorkOrderMediaDataService(context); var service = new WorkOrderMediaService(mediaData, 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(new WorkOrderMediaDataService(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(new WorkOrderMediaDataService(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); } }