shoc-backend/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
Arthur Bassi c594d9f689 fix(work-orders): derive Past Due from DueDate instead of ScheduledDate
Past Due must track the deadline (Due Date), not Schedule On. Keep dueDate and scheduledDate PATCH mutations independent so rescheduling alone does not clear Past Due.
2026-07-28 14:01:22 -03:00

654 lines
24 KiB
C#

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<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
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<ApplicationDbContext>()
.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,
DueDate = DateTime.UtcNow.AddDays(-2),
ScheduledDate = DateTime.UtcNow.AddDays(3),
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<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<ApplicationDbContext>()
.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<ApplicationDbContext>()
.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<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<ApplicationDbContext>()
.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<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
service.DeleteMediaAsync(1, 10, "actor-1"));
Assert.Equal("ReadOnly", ex.Code);
}
[Fact]
public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1"));
Assert.Equal("UseCompletionDocEndpoint", ex.Code);
}
}