using System.Security.Claims; using System.Text; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; 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 resolver = WorkOrderAccountTestHelpers.Resolver(context); var boardService = new WorkOrderBoardService(boardData, resolver); var detailData = new WorkOrderDetailDataService(context); var templateData = new CompletionDocTemplateDataService(context); var userData = new UserDataService(context); var service = new WorkOrderDetailService(boardService, detailData, templateData, userData, resolver); 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(3), 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, WorkOrderAccountTestHelpers.OrgWideAdmin()); 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 GetDetail_SurfacesPendingUpliftCountFromBoardRow() { var (context, service) = CreateSut(); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1, ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) }); context.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "10000000001", LifecycleStatus = LifecycleStatus.Scheduled, ScheduledDate = DateTime.UtcNow.AddDays(1), PrimaryDispatchId = 10, SiteCode = "BK5", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.DispatchUpliftRequests.Add(new DispatchUpliftRequest { Id = 100, DispatchId = 10, RequestedNTE = 1500m, Status = "Pending", RequiredTier = 1, NotificationStatus = "Pending" }); await context.SaveChangesAsync(); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.Equal(1, detail!.Info.PendingUpliftCount); Assert.NotNull(detail.Info.UpliftSummary); Assert.True(detail.Info.UpliftSummary!.HasUplift); Assert.Equal(1, detail.Info.UpliftSummary.PendingCount); Assert.Equal("pending", detail.Info.UpliftSummary.PrimaryStatus); Assert.Equal(1500m, detail.Info.UpliftSummary.Amount); } [Fact] public async Task GetDetail_SurfacesSeverityFromBoardRow() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "10000000001", LifecycleStatus = LifecycleStatus.Scheduled, WorkOrderType = WorkOrderType.Emergency, Severity = "2", ScheduledDate = DateTime.UtcNow.AddDays(1), SiteCode = "BK5", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.Equal(2, detail!.Info.Severity); } [Fact] public async Task GetDetail_InactivePrimaryDispatch_OmitsVendorAndMapsStatus() { var (context, service) = CreateSut(); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1, Status = "Refused", ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) }); context.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "10000000001", LifecycleStatus = LifecycleStatus.Scheduled, ScheduledDate = DateTime.UtcNow.AddDays(1), PrimaryDispatchId = 10, SiteCode = "BK5", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.Null(detail!.Info.VendorId); Assert.Null(detail.Info.VendorName); Assert.Equal("Refused", detail.Info.PrimaryDispatchStatus); } [Fact] public async Task GetDetail_LiveDispatchWithEmptyCompanyName_KeepsVendorIdAndMapsStatus() { var (context, service) = CreateSut(); context.Vendors.Add(new Vendor { Id = 1, CompanyName = "" }); context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1, Status = "Sent", ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) }); context.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "10000000001", LifecycleStatus = LifecycleStatus.Scheduled, ScheduledDate = DateTime.UtcNow.AddDays(1), PrimaryDispatchId = 10, SiteCode = "BK5", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.Equal(1, detail!.Info.VendorId); Assert.Equal("Sent", detail.Info.PrimaryDispatchStatus); } [Fact] public async Task GetDetail_ReturnsAdditionalContactsFromBoardRow() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, InternalWONumber = "10000000218", LifecycleStatus = LifecycleStatus.Scheduled, ScheduledDate = DateTime.UtcNow.AddDays(1), AdditionalContacts = "[{\"name\":\"Backup Lead\",\"phone\":\"+1 555-0101\",\"notes\":\"After hours\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.NotNull(detail!.Info.AdditionalContacts); Assert.Equal(2, detail.Info.AdditionalContacts!.Count); Assert.Equal("Backup Lead", detail.Info.AdditionalContacts[0].Name); Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone); Assert.Equal("After hours", detail.Info.AdditionalContacts[0].Notes); Assert.Equal("Security Desk", detail.Info.AdditionalContacts[1].Name); Assert.Equal("+1 555-0102", detail.Info.AdditionalContacts[1].Phone); } [Fact] public async Task GetDetail_ReflectsPatchedAdditionalContacts() { var (context, service) = CreateSut(); var wo = new WorkOrder { Id = 1, InternalWONumber = "10000000219", LifecycleStatus = LifecycleStatus.Incomplete, AdditionalContacts = "[{\"name\":\"Old Backup\",\"phone\":\"+1 555-0000\"}]", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); await context.SaveChangesAsync(); var mutationData = new WorkOrderBoardMutationDataService(context); var boardData = new WorkOrderBoardDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.AdditionalContacts, Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]", WorkOrderVersion = Convert.ToBase64String(wo.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) }, "actor-1"); var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); Assert.NotNull(detail); Assert.Single(detail!.Info.AdditionalContacts!); Assert.Equal("Jane Backup", detail.Info.AdditionalContacts![0].Name); Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone); Assert.Equal("Call first", detail.Info.AdditionalContacts[0].Notes); } [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, WorkOrderAccountTestHelpers.OrgWideAdmin()); 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, WorkOrderAccountTestHelpers.Resolver(context)); 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", WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "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", WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "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", WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "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", WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "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", WorkOrderAccountTestHelpers.OrgWideAdmin("actor-1"), "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, WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.AddCommentAsync( 1, new WorkOrderCommentCreateDto { Text = "Note" }, WorkOrderAccountTestHelpers.OrgWideAdmin("user-abc"), "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, WorkOrderAccountTestHelpers.Resolver(context)); var comments = await service.GetCommentsAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); 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), WorkOrderAccountTestHelpers.Resolver(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 " }, WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "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" }, WorkOrderAccountTestHelpers.OrgWideAdmin("admin-user"), "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" }, WorkOrderAccountTestHelpers.OrgWideAdmin("other-user"), "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" }, WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "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" }, WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "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" }, WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), "user-author")); Assert.Equal("NotFound", ex.Code); } } public class WorkOrderMediaServiceTests { private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var context = new ApplicationDbContext(options); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit); return (context, service); } private static string ToVersion(WorkOrder workOrder) => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); private static ClaimsPrincipal AuthenticatedUser( string actorId = "actor-1", string role = "Admin", int? accountId = null, bool omitScopeClaims = false) { var claims = new List { new Claim(ClaimTypes.NameIdentifier, actorId), new Claim(ClaimTypes.Role, role) }; if (!omitScopeClaims) { if (accountId.HasValue) claims.Add(new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString())); else if (role == "Admin") claims.Add(new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)); } var identity = new ClaimsIdentity(claims, authenticationType: "Test"); return new ClaimsPrincipal(identity); } private static ClaimsPrincipal AuthenticatedWithMalformedAccountClaim(string actorId = "actor-1") { var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, actorId), new Claim(ClaimTypes.Role, "Admin"), new Claim(SeaHavenClaimTypes.AccountId, "not-an-int") }, authenticationType: "Test"); return new ClaimsPrincipal(identity); } private static ClaimsPrincipal AuthenticatedWithoutRole(string actorId = "actor-1") { var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, actorId) }, authenticationType: "Test"); return new ClaimsPrincipal(identity); } private static ClaimsPrincipal UnauthenticatedUser() => new ClaimsPrincipal(new ClaimsIdentity()); [Fact] public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra() { var (context, service) = CreateSut(); 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 media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-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 GetMedia_IncludesAvetaCategory() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1 }); context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = "https://example.com/aveta.pdf", Category = WorkOrderMediaCategory.Aveta }); await context.SaveChangesAsync(); var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); Assert.NotNull(media); Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Aveta); } [Fact] public async Task AddMedia_Aveta_PersistsCategory() { 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 media = await service.AddMediaAsync( 1, WorkOrderMediaCategory.Aveta, "https://example.com/aveta.pdf", AuthenticatedUser(), "actor-1"); Assert.True(media.Id > 0); Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category); Assert.Equal(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category); } [Fact] public async Task AddMedia_Extra_DoesNotPersistAsAveta() { 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 media = await service.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/extra.pdf", AuthenticatedUser(), "actor-1"); Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); Assert.Equal(WorkOrderMediaCategory.Extra, context.workOrderAttachments.Single().Category); Assert.NotEqual(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category); } [Fact] public async Task UpdateMediaCategory_ExtraToAveta() { 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); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.com/extra.pdf", Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var media = await service.UpdateMediaCategoryAsync( 1, 10, WorkOrderMediaCategory.Aveta, ToVersion(wo), AuthenticatedUser(), "actor-1"); Assert.Equal(WorkOrderMediaCategory.Aveta, media.Category); Assert.Equal(WorkOrderMediaCategory.Aveta, context.workOrderAttachments.Single().Category); Assert.True(context.workOrderAttachments.Single().IsDeleted != true); } [Fact] public async Task GetMedia_TechnicianOnAssignedWorkOrder_Succeeds() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 1, AssignTo = "tech-1", BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var media = await service.GetMediaAsync( 1, AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1"); Assert.NotNull(media); Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Before); } [Fact] public async Task GetMedia_TechnicianOnUnassignedWorkOrder_ThrowsNotFound() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 1, AssignTo = "other-tech", BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.GetMediaAsync(1, AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task GetMedia_CallerWithoutRole_ThrowsForbidden() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.GetMediaAsync(1, AuthenticatedWithoutRole(), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task GetMedia_UnauthenticatedCaller_ThrowsForbidden() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.GetMediaAsync(1, UnauthenticatedUser(), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task GetMedia_CanceledToken_ThrowsOperationCanceled() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); using var cts = new CancellationTokenSource(); cts.Cancel(); await Assert.ThrowsAnyAsync(() => service.GetMediaAsync(1, AuthenticatedUser(), "actor-1", cts.Token)); } /// /// ApplyBaseScope filter: deleted work orders → null / no media disclosure. /// (Not a cross-tenant test — no tenant key exists yet.) /// [Fact] public async Task GetMedia_StaffOnDeletedWorkOrder_ReturnsNull_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, IsDeleted = true, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); Assert.Null(media); } /// /// ApplyBaseScope filter: template work orders are excluded. /// [Fact] public async Task GetMedia_StaffOnTemplateWorkOrder_ReturnsNull_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, istemplate = true, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); Assert.Null(media); } [Fact] public async Task GetMedia_MissingWorkOrder_ReturnsNull_OutsideBaseScope() { var (_, service) = CreateSut(); var media = await service.GetMediaAsync(99, AuthenticatedUser(), "actor-1"); Assert.Null(media); } [Fact] public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Canceled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.com/extra.pdf", Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.DeleteMediaAsync(1, 10, ToVersion(context.workOrders.Single()), AuthenticatedUser(), "actor-1")); Assert.Equal("ReadOnly", ex.Code); } [Fact] public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint() { 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.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", AuthenticatedUser(), "actor-1")); Assert.Equal("UseCompletionDocEndpoint", ex.Code); } [Fact] public async Task AddMedia_WithoutCategory_PersistsAsUncategorizedExtra() { 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 media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", AuthenticatedUser(), "actor-1"); Assert.True(media.Id > 0); Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); Assert.Null(context.workOrderAttachments.Single().Category); Assert.Contains(context.WorkOrderAuditLogs, a => a.FieldName == "MediaCategory"); } [Fact] public async Task AddMedia_MissingActor_ThrowsForbidden() { 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.AddMediaAsync(1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser(), null)); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task AddMedia_UnauthenticatedCaller_ThrowsForbidden() { 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.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", UnauthenticatedUser(), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task AddMedia_CallerWithoutRole_ThrowsForbidden() { 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.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedWithoutRole(), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task AddMedia_TechnicianOnUnassignedWorkOrder_ThrowsNotFound() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 1, AssignTo = "other-tech", LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task AddMedia_TechnicianOnAssignedWorkOrder_Succeeds() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 1, AssignTo = "tech-1", LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var media = await service.AddMediaAsync( 1, null, "https://example.com/photo.jpg", AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1"); Assert.True(media.Id > 0); Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); } [Fact] public async Task DeleteMedia_Technician_ThrowsForbidden() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 1, AssignTo = "tech-1", LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.com/extra.pdf", Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.DeleteMediaAsync( 1, 10, ToVersion(context.workOrders.Single()), AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1")); Assert.Equal("Forbidden", ex.Code); } /// /// ApplyBaseScope mutation: missing id → NotFound (no disclosure). /// [Fact] public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound_OutsideBaseScope() { var (_, service) = CreateSut(); var ex = await Assert.ThrowsAsync(() => service.EnsureCanMutateMediaAsync(99, AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); } /// /// ApplyBaseScope mutation: deleted WO → NotFound. /// [Fact] public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, IsDeleted = true, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); } /// /// ApplyBaseScope mutation: template WO → NotFound. /// [Fact] public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound_OutsideBaseScope() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, istemplate = true, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment() { 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); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.com/photo.jpg", Category = null }); await context.SaveChangesAsync(); var media = await service.UpdateMediaCategoryAsync( 1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1"); Assert.Equal(-1, media.Id); Assert.Equal(WorkOrderMediaCategory.Before, media.Category); Assert.Equal("https://example.com/photo.jpg", context.workOrders.Single().BeforPhotoAttachment); Assert.True(context.workOrderAttachments.Single().IsDeleted); Assert.Contains(context.WorkOrderAuditLogs, a => a.FieldName == "MediaCategory" && a.NewValue == "10:Before"); } [Fact] public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } }); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.com/photo.jpg", Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var stale = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); var ex = await Assert.ThrowsAsync(() => service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, AuthenticatedUser(), "actor-1")); Assert.Equal("ConcurrencyConflict", ex.Code); } [Fact] public async Task UpdateMediaCategory_CrossWorkOrderMedia_ThrowsNotFound() { 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 } }); context.workOrders.Add(new WorkOrder { Id = 2, LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 2, Attachments = "https://example.com/photo.jpg", Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var version = ToVersion(context.workOrders.Single(w => w.Id == 1)); var ex = await Assert.ThrowsAsync(() => service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task UpdateMediaCategory_DeletedMedia_ThrowsNotFound() { 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); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.com/photo.jpg", Category = WorkOrderMediaCategory.Extra, IsDeleted = true }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task GetMedia_StaffWithAccountClaim_CrossAccount_ReturnsNull() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 20, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var media = await service.GetMediaAsync(1, AuthenticatedUser(accountId: 10), "actor-1"); Assert.Null(media); } [Fact] public async Task GetMedia_StaffWithAccountClaim_SameAccount_Succeeds() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 10, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var media = await service.GetMediaAsync(1, AuthenticatedUser(accountId: 10), "actor-1"); Assert.NotNull(media); Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Before); } [Fact] public async Task GetMedia_StaffWithOrgScopeClaim_Succeeds() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 99, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); Assert.NotNull(media); } [Fact] public async Task GetMedia_StaffWithoutScopeClaim_ThrowsForbidden() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 99, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.GetMediaAsync(1, AuthenticatedUser(omitScopeClaims: true), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task GetMedia_MalformedAccountClaim_ThrowsForbidden() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 10, BeforPhotoAttachment = "https://example.com/before.jpg" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.GetMediaAsync(1, AuthenticatedWithMalformedAccountClaim(), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task AddMedia_StaffWithoutScopeClaim_ThrowsForbidden() { 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.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser(role: "Dispatcher", omitScopeClaims: true), "actor-1")); Assert.Equal("Forbidden", ex.Code); } [Fact] public async Task AddMedia_StaffWithAccountClaim_CrossAccount_ThrowsNotFound() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 20, LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser(accountId: 10), "actor-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task AddMedia_TechnicianAssigned_CrossAccount_ThrowsNotFound() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 20, AssignTo = "tech-1", LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser("tech-1", "User", accountId: 10), "tech-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task AddMedia_AfterPreCheck_BecomesReadOnly_ThrowsReadOnly_OnFreshLoad() { 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(); await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"); var wo = await context.workOrders.SingleAsync(w => w.Id == 1); wo.LifecycleStatus = LifecycleStatus.Canceled; await context.SaveChangesAsync(); context.ChangeTracker.Clear(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser(), "actor-1")); Assert.Equal("ReadOnly", ex.Code); } [Fact] public async Task AddMedia_AfterPreCheck_AssignmentChanged_ThrowsNotFound_OnFreshLoad() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 1, AssignTo = "tech-1", LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1"); var wo = await context.workOrders.SingleAsync(w => w.Id == 1); wo.AssignTo = "other-tech"; await context.SaveChangesAsync(); context.ChangeTracker.Clear(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1")); Assert.Equal("NotFound", ex.Code); } } public class WorkOrderMediaFileRulesTests { private static FormFile FormFile(byte[] bytes, string fileName, string contentType) => new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) { Headers = new HeaderDictionary(), ContentType = contentType }; [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg"))); } [Fact] public void IsAllowed_SpoofedExtension_ReturnsFalse() { var html = Encoding.UTF8.GetBytes("not an image"); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", "image/jpeg"))); } [Fact] public void IsAllowed_SpoofedMime_ReturnsFalse() { var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.exe", "image/jpeg"))); } [Fact] public void IsAllowed_MimeOrExtensionAlone_ReturnsFalse() { var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.bin", "application/octet-stream"))); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(new byte[] { 1, 2, 3 }, "photo.jpg", "image/jpeg"))); } [Fact] public void IsAllowed_ValidMp4Ftyp_ReturnsTrue() { // size(4) + ftyp(4) + isom(4) var mp4 = new byte[] { 0x00, 0x00, 0x00, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m' }; Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(mp4, "clip.mp4", "video/mp4"))); } [Fact] public void EnsureAllowed_MalformedBytes_ThrowsUnsupportedMediaType() { var ex = Assert.Throws(() => WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png"))); Assert.Equal("UnsupportedMediaType", ex.Code); } }