using System.Security.Claims; using System.Text; using System.IO.Compression; using System.Reflection; using System.Text.RegularExpressions; 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; using SeaHaven.Services.Interfaces; 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 = WorkOrderAccountTestHelpers.BoardUpdateService(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 }); [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 = WorkOrderAccountTestHelpers.BoardUpdateService(context, 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( IFileStoragePort? fileStorage = null) { 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, fileStorage ?? new TestFileStoragePort(), new UpliftDataService(context)); 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 AddMedia_EleventhPhoto_ThrowsMediaCountExceeded() { 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 } }); for (var i = 0; i < 10; i++) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = $"https://example.com/photo-{i}.jpg", Category = WorkOrderMediaCategory.Extra }); } await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, null, "https://example.com/photo-10.jpg", AuthenticatedUser(), "actor-1")); Assert.Equal("MediaCountExceeded", ex.Code); Assert.Equal("A work order can have at most 10 photos.", ex.Message); Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); } [Fact] public async Task AddMedia_CountsAndInsertsUnderTheWorkOrderMutationLock() { // Distinct id: the mutation gate is process-wide per work order. const int workOrderId = 173_001; var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = workOrderId, LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); for (var i = 0; i < 9; i++) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = workOrderId, Attachments = $"https://example.com/photo-{i}.jpg", Category = WorkOrderMediaCategory.Extra }); } await context.SaveChangesAsync(); var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var holder = new UpliftDataService(context).ExecuteWorkOrderMutationAsync( workOrderId, async _ => { held.SetResult(); await release.Task; return 0; }, CancellationToken.None); await held.Task; var upload = service.AddMediaAsync( workOrderId, null, "https://example.com/photo-9.jpg", AuthenticatedUser(), "actor-1"); await Task.Delay(200); Assert.False(upload.IsCompleted); Assert.Equal(9, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true)); release.SetResult(); await holder; await upload; Assert.Equal(10, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true)); } [Fact] public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls() { // Same URL shape FileStorageAdapter.SaveFileAsync returns for an iPhone upload. static string StoredUrl(string name) => $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}"; 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 } }); foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.mov", "clip.MP4" }) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = StoredUrl(name), Category = WorkOrderMediaCategory.Extra }); } await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, null, StoredUrl("IMG_0004.MOV"), AuthenticatedUser(), "actor-1")); Assert.Equal("MediaCountExceeded", ex.Code); Assert.Equal("A work order can have at most 3 videos.", ex.Message); } [Fact] public async Task AddMedia_FourthVideo_CountsVendorPortalVideos() { 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 } }); foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", Category = WorkOrderMediaCategory.Extra }); } // Vendor v1 video was replaced by v2 (also a video): only v2 is current. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1, ContentType = "video/mp4", Purpose = "Completion" }); context.VendorCompletionDocuments.Add(new VendorCompletionDocument { Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2, ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50 }); // Another work order's vendor video never counts here. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1, ContentType = "video/mp4", Purpose = "Completion" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, null, "https://api.example.com/Assets/Documents/x_IMG_0004.MOV", AuthenticatedUser(), "actor-1")); Assert.Equal("MediaCountExceeded", ex.Code); Assert.Equal("A work order can have at most 3 videos.", ex.Message); Assert.Equal(2, context.workOrderAttachments.Count()); } [Fact] public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount() { 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 } }); for (var i = 0; i < 10; i++) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = $"https://example.com/photo-{i}.jpg", Category = WorkOrderMediaCategory.Extra }); } await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, null, "https://example.com/photo-10.jpg", AuthenticatedUser(accountId: 10), "actor-1")); // Another account must not learn the work order exists or how full it is. Assert.Equal("NotFound", ex.Code); Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); } [Fact] public async Task AddMedia_TenthPhoto_Succeeds() { 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 } }); for (var i = 0; i < 9; i++) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = $"https://example.com/photo-{i}.jpg", Category = WorkOrderMediaCategory.Extra }); } await context.SaveChangesAsync(); var media = await service.AddMediaAsync( 1, null, "https://example.com/photo-9.jpg", AuthenticatedUser(), "actor-1"); Assert.True(media.Id > 0); } [Fact] public async Task AddMedia_FourthVideo_ThrowsMediaCountExceeded() { 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.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = "https://example.com/clip-a.mp4", Category = WorkOrderMediaCategory.Extra }); context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = "https://example.com/clip-b.mov", Category = WorkOrderMediaCategory.Extra }); context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = "https://example.com/clip-c.mp4", Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync( 1, null, "https://example.com/clip-d.mov", AuthenticatedUser(), "actor-1")); Assert.Equal("MediaCountExceeded", ex.Code); Assert.Equal("A work order can have at most 3 videos.", ex.Message); } [Fact] public async Task AddMedia_ThirdVideo_Succeeds() { 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.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = "https://example.com/clip-a.mp4", Category = WorkOrderMediaCategory.Extra }); context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = "https://example.com/clip-b.mov", Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var media = await service.AddMediaAsync( 1, null, "https://example.com/clip-c.mp4", AuthenticatedUser(), "actor-1"); Assert.True(media.Id > 0); } [Fact] public async Task AddMedia_DeletedPhoto_DoesNotConsumePhotoCount() { 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 } }); for (var i = 0; i < 10; i++) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = $"https://example.com/photo-{i}.jpg", Category = WorkOrderMediaCategory.Extra, IsDeleted = i == 0 }); } await context.SaveChangesAsync(); var media = await service.AddMediaAsync( 1, null, "https://example.com/photo-new.jpg", AuthenticatedUser(), "actor-1"); Assert.True(media.Id > 0); } [Fact] public async Task AddMedia_DocumentsDoNotConsumePhotoOrVideoCounts() { 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 } }); for (var i = 0; i < 5; i++) { context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = 1, Attachments = $"https://example.com/report-{i}.pdf", Category = WorkOrderMediaCategory.Extra }); } await context.SaveChangesAsync(); var photo = await service.AddMediaAsync( 1, null, "https://example.com/photo.jpg", AuthenticatedUser(), "actor-1"); var video = await service.AddMediaAsync( 1, null, "https://example.com/clip.mp4", AuthenticatedUser(), "actor-1"); Assert.True(photo.Id > 0); Assert.True(video.Id > 0); } [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"); } [Theory] [InlineData("https://example.com/document.pdf")] [InlineData("https://example.com/document.doc")] [InlineData("https://example.com/document.docx")] public async Task UpdateMediaCategory_DocumentToPhotoCategory_ThrowsUnsupportedMediaType( string attachmentUrl) { 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 = attachmentUrl, Category = WorkOrderMediaCategory.Extra }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.UpdateMediaCategoryAsync( 1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1")); Assert.Equal("UnsupportedMediaType", ex.Code); Assert.Null(context.workOrders.Single().BeforPhotoAttachment); Assert.True(context.workOrderAttachments.Single().IsDeleted != true); } [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); } [Fact] public async Task ExtraPdf_ListContentDeleteContent_FollowsSoftDeleteLifecycle() { var storage = new TestFileStoragePort(_ => new MemoryStream(Encoding.ASCII.GetBytes("%PDF-1.7"))); var (context, service) = CreateSut(storage); var workOrder = new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(workOrder); await context.SaveChangesAsync(); var fileUrl = "https://example.test/Assets/Documents/11111111-1111-1111-1111-111111111111_report.pdf"; var added = await service.AddMediaAsync( 1, WorkOrderMediaCategory.Extra, fileUrl, AuthenticatedUser(), "actor-1"); var listed = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); Assert.Contains(listed!, candidate => candidate.Id == added.Id); var content = await service.GetMediaContentAsync( 1, added.Id, AuthenticatedUser(), "actor-1"); using (content.Content) { Assert.Equal("application/pdf", content.ContentType); Assert.Equal("report.pdf", content.FileName); } await service.DeleteMediaAsync( 1, added.Id, ToVersion(workOrder), AuthenticatedUser(), "actor-1"); var ex = await Assert.ThrowsAsync(() => service.GetMediaContentAsync(1, added.Id, AuthenticatedUser(), "actor-1")); Assert.Equal("NotFound", ex.Code); Assert.Equal(0, storage.TryDeleteCalls); } [Fact] public async Task GetMediaContent_CrossAccount_ThrowsNotFound() { var storage = new TestFileStoragePort(_ => new MemoryStream([1])); var (context, service) = CreateSut(storage); context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 20 }); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = "https://example.test/Assets/Documents/report.pdf" }); await context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => service.GetMediaContentAsync( 1, 10, AuthenticatedUser(accountId: 10), "actor-1")); Assert.Equal("NotFound", ex.Code); } [Fact] public async Task GetMediaContent_CanceledToken_ForwardsCancellation() { var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1 }); await context.SaveChangesAsync(); using var source = new CancellationTokenSource(); source.Cancel(); await Assert.ThrowsAnyAsync(() => service.GetMediaContentAsync( 1, 10, AuthenticatedUser(), "actor-1", source.Token)); } } public class WorkOrderCompletionDocFileRulesTests { private static readonly byte[] Pdf = Encoding.ASCII.GetBytes("%PDF-1.7\n1 0 obj\n"); 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_ValidPdf_ReturnsTrue() { Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/pdf"))); } [Fact] public void IsAllowed_PdfWithUndeterminedContentType_ReturnsTrue() { // The browser leaves File.type empty when the OS cannot determine it, and the // completion-doc dialog already lets that through on a .pdf name. Rejecting it // would break uploads that work today. Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", ""))); Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/octet-stream"))); } [Fact] public void IsAllowed_PdfExtensionWithNonPdfContent_ReturnsFalse() { var html = Encoding.UTF8.GetBytes("not a pdf"); Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(html, "completion.pdf", "application/pdf"))); } [Fact] public void IsAllowed_PdfContentUnderDisallowedExtension_ReturnsFalse() { Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.exe", "application/pdf"))); Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion", "application/pdf"))); } [Fact] public void IsAllowed_DisallowedContentType_ReturnsFalse() { var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg"))); Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "text/html"))); } [Fact] public void IsAllowed_EmptyFile_ReturnsFalse() { Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Array.Empty(), "completion.pdf", "application/pdf"))); } [Fact] public void EnsureAllowed_RejectedFile_ThrowsUnsupportedMediaType() { var html = Encoding.UTF8.GetBytes("not a pdf"); var ex = Assert.Throws( () => WorkOrderCompletionDocFileRules.EnsureAllowed(FormFile(html, "completion.pdf", "application/pdf"))); Assert.Equal("UnsupportedMediaType", ex.Code); } [Fact] public void MediaRules_StillRejectPdfOutsideDocumentCategories() { // The completion-doc allowlist must not widen the SH-116 media allowlist. // SH-171 since added documents to media for Extra and Aveta, which is a // deliberate widening of its own; what this guards is that it stopped // there. Completion is the category SH-337 touches, and a photo slot // must still refuse a PDF. Assert.False(WorkOrderMediaFileRules.IsAllowed( FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Completion)); Assert.False(WorkOrderMediaFileRules.IsAllowed( FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Before)); Assert.False(WorkOrderMediaFileRules.IsAllowed( FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.After)); } } 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 }; private static byte[] DocxBytes(bool includeWordEntry) { using var stream = new MemoryStream(); using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) { var entry = archive.CreateEntry(includeWordEntry ? "word/document.xml" : "not-word/content.xml"); using var writer = new StreamWriter(entry.Open()); writer.Write(""); } return stream.ToArray(); } [Fact] public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType() { const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static; var allowedTypes = (HashSet?)typeof(WorkOrderMediaFileRules) .GetField("AllowedContentTypes", privateStatic)?.GetValue(null); var extensionsByType = (Dictionary>?)typeof(WorkOrderMediaFileRules) .GetField("ExtensionsByContentType", privateStatic)?.GetValue(null); Assert.NotNull(allowedTypes); Assert.NotNull(extensionsByType); Assert.NotEmpty(allowedTypes); var ex = Assert.Throws( () => WorkOrderMediaFileRules.EnsureAllowed(FormFile(Encoding.UTF8.GetBytes("plain text"), "notes.txt", "text/plain"))); foreach (var contentType in allowedTypes) { var canonical = contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : contentType; Assert.True(extensionsByType.TryGetValue(canonical, out var extensions), $"No extensions mapped for {contentType}."); var labels = extensions.Select(extension => extension.TrimStart('.').ToUpperInvariant()).ToList(); Assert.True( labels.Any(label => Regex.IsMatch(ex.Message, $@"\b{Regex.Escape(label)}\b")), $"Rejection message does not name {contentType} ({string.Join("/", labels)}): {ex.Message}"); } } [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_ImageJpgAlias_ReturnsTrue() { var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpg"))); } [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"))); } // Signatures below are the leading bytes of files produced by real encoders: // ffmpeg (mp4 isom, mov qt, jpeg JFIF, png) and an iPhone/macOS .MOV (ftyp qt). private static readonly byte[] RealJpeg = { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01 }; private static readonly byte[] RealPng = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52 }; private static readonly byte[] RealMp4 = { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00 }; private static readonly byte[] RealMov = { 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00 }; private static readonly byte[] RealPdf = Encoding.ASCII.GetBytes("%PDF-1.7\n%\u00e2\u00e3\n1 0 obj\n"); [Theory] [InlineData("photo.jpg", "image/jpeg")] [InlineData("IMG_0001.JPG", "image/jpeg")] [InlineData("photo.jpeg", "image/jpg")] [InlineData("scan.png", "image/png")] [InlineData("clip.mp4", "video/mp4")] [InlineData("VID_0001.MP4", "video/mp4")] [InlineData("IMG_1587.MOV", "video/quicktime")] public void IsAllowed_RealMediaSignatures_ReturnsTrueForEveryCategory(string fileName, string contentType) { var bytes = BytesFor(fileName); foreach (var category in new[] { WorkOrderMediaCategory.Extra, WorkOrderMediaCategory.Before, WorkOrderMediaCategory.After }) { Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(bytes, fileName, contentType), category)); } } [Theory] [InlineData("photo.jpg", "")] [InlineData("IMG_0001.JPG", "application/octet-stream")] [InlineData("scan.png", "")] [InlineData("clip.mp4", "application/octet-stream")] [InlineData("IMG_1587.MOV", "")] [InlineData("IMG_1587.mov", "application/octet-stream")] public void IsAllowed_UndeterminedMimeFromMobileBrowser_ResolvesFromExtensionAndSignature( string fileName, string contentType) { // Some mobile browsers leave File.type empty (or send octet-stream) when the OS // cannot classify a picked file; the extension plus magic bytes must decide. Assert.True(WorkOrderMediaFileRules.IsAllowed( FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before)); } [Fact] public void IsAllowed_UndeterminedMime_StillRequiresMatchingSignature() { var html = Encoding.UTF8.GetBytes("not an image"); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", ""))); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPng, "photo.jpg", "application/octet-stream"))); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mov", ""))); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.heic", ""))); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo", "application/octet-stream"))); } [Theory] [InlineData(WorkOrderMediaCategory.Completion)] [InlineData(WorkOrderMediaCategory.Before)] [InlineData(WorkOrderMediaCategory.After)] public void IsAllowed_UndeterminedMimePdf_StillRefusedOutsideDocumentCategories(WorkOrderMediaCategory category) { // SH-337 / SH-171: resolving an empty MIME from ".pdf" must not reopen photo slots to documents. Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPdf, "report.pdf", ""), category)); Assert.False(WorkOrderMediaFileRules.IsAllowed( FormFile(RealPdf, "report.pdf", "application/octet-stream"), category)); } [Fact] public void IsAllowed_UndeterminedMimePdf_AcceptedForExtra() { Assert.True(WorkOrderMediaFileRules.IsAllowed( FormFile(RealPdf, "report.pdf", ""), WorkOrderMediaCategory.Extra)); } [Fact] public void IsAllowed_AllowlistedDeclaredMimeMismatchingExtension_StillRejected() { // An allowlisted declared type stays authoritative and pairs against its own // extension, so declaring video/mp4 for a .mov (or the reverse) is still a spoof. Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4"))); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.mp4", "video/quicktime"))); } [Theory] // SH-381: mobile browsers attach a foreign-but-plausible type to a supported container. // The extension plus magic bytes must decide, not the unreliable declared MIME. [InlineData("clip.mp4", "video/3gpp")] [InlineData("VID_0001.MP4", "video/3gpp")] [InlineData("IMG_1587.MOV", "video/x-quicktime")] [InlineData("IMG_1587.mov", "video/mpeg")] [InlineData("photo.jpg", "image/heic")] public void IsAllowed_ForeignDeclaredMime_ResolvesFromExtensionAndSignature( string fileName, string contentType) { Assert.True(WorkOrderMediaFileRules.IsAllowed( FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before)); } [Fact] public void IsAllowed_ForeignDeclaredMime_StillRequiresMatchingSignatureAndExtension() { // Falling back to the extension does not weaken the gate: the bytes must still match // the resolved type, the extension must still be supported, and a resolved document // stays out of photo/video categories. Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mp4", "video/3gpp"))); Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.exe", "video/3gpp"))); Assert.False(WorkOrderMediaFileRules.IsAllowed( FormFile(RealPdf, "report.pdf", "application/x-unknown"), WorkOrderMediaCategory.Before)); } private static byte[] BytesFor(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch { ".jpg" or ".jpeg" => RealJpeg, ".png" => RealPng, ".mp4" => RealMp4, ".mov" => RealMov, ".pdf" => RealPdf, _ => throw new ArgumentOutOfRangeException(nameof(fileName)) }; [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); } [Theory] [InlineData(WorkOrderMediaCategory.Extra)] [InlineData(WorkOrderMediaCategory.Aveta)] public void EnsureAllowed_PdfForDocumentCategory_Succeeds(WorkOrderMediaCategory category) { var pdf = Encoding.ASCII.GetBytes("%PDF-1.7"); WorkOrderMediaFileRules.EnsureAllowed( FormFile(pdf, "report.pdf", "application/pdf"), category); } [Fact] public void EnsureAllowed_DocWithOleSignatureForExtra_Succeeds() { var doc = new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0x00 }; WorkOrderMediaFileRules.EnsureAllowed( FormFile(doc, "report.doc", "application/msword"), WorkOrderMediaCategory.Extra); } [Fact] public void EnsureAllowed_RealDocxForExtra_Succeeds() { WorkOrderMediaFileRules.EnsureAllowed( FormFile( DocxBytes(includeWordEntry: true), "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), WorkOrderMediaCategory.Extra); } [Theory] [InlineData(WorkOrderMediaCategory.Before)] [InlineData(WorkOrderMediaCategory.After)] public void EnsureAllowed_PdfForPhotoCategory_ThrowsUnsupportedMediaType( WorkOrderMediaCategory category) { var ex = Assert.Throws(() => WorkOrderMediaFileRules.EnsureAllowed( FormFile(Encoding.ASCII.GetBytes("%PDF-1.7"), "report.pdf", "application/pdf"), category)); Assert.Equal("UnsupportedMediaType", ex.Code); } [Fact] public void EnsureAllowed_ZipWithoutWordEntry_ThrowsUnsupportedMediaType() { var ex = Assert.Throws(() => WorkOrderMediaFileRules.EnsureAllowed( FormFile( DocxBytes(includeWordEntry: false), "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), WorkOrderMediaCategory.Extra)); Assert.Equal("UnsupportedMediaType", ex.Code); } }