diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 55c2dbf..0867b1a 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -66,7 +66,7 @@ namespace Api.SeaHavenIndustries.Controllers { WorkOrderMediaFileRules.EnsureAllowed(file); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken); + await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category); fileUrl = await _fileStorage.SaveFileAsync(file); var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, User, actorId, cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 3589d36..7a52474 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -9,6 +9,33 @@ namespace SeaHaven.Services.Helpers public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status); + public static bool IsFullyLocked(LifecycleStatus? status) + => status == LifecycleStatus.Canceled; + + public static bool IsCoreLocked(LifecycleStatus? status) + => status == LifecycleStatus.Completed; + + public static bool CanPatchBoardField(LifecycleStatus? status, string field) + { + if (IsFullyLocked(status)) + return false; + if (!IsCoreLocked(status)) + return true; + return field.Equals(WorkOrderBoardFieldNames.FlagColor, StringComparison.OrdinalIgnoreCase); + } + + public static bool CanMutateComments(LifecycleStatus? status) + => !IsFullyLocked(status); + + public static bool CanMutateExtraMedia(LifecycleStatus? status, WorkOrderMediaCategory? category) + { + if (IsFullyLocked(status)) + return false; + if (!IsCoreLocked(status)) + return true; + return (category ?? WorkOrderMediaCategory.Extra) == WorkOrderMediaCategory.Extra; + } + public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 5244915..5a11b21 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -59,8 +59,13 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardConcurrencyException(staleState); } - if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) - throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status."); + if (!WorkOrderBoardMutationRules.CanPatchBoardField(workOrder.LifecycleStatus, canonicalField)) + { + var code = WorkOrderBoardMutationRules.IsFullyLocked(workOrder.LifecycleStatus) + ? "CanceledReadOnly" + : "ReadOnly"; + throw new WorkOrderBoardValidationException(code, "Work order is read-only in its current status."); + } var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus); if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue)) diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs index 8d65ef1..c039cfc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -48,9 +49,13 @@ namespace SeaHaven.Services.Implementation string? documentUrl = null) { var accountId = _accountResolver.ResolveAccountFilter(user); - if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId)) + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, CancellationToken.None, accountId); + if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + EnsureCommentMutationAllowed(workOrder.LifecycleStatus); + if (string.IsNullOrWhiteSpace(request.Text)) throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required."); @@ -83,8 +88,7 @@ namespace SeaHaven.Services.Implementation if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) - throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + EnsureCommentMutationAllowed(workOrder.LifecycleStatus); if (string.IsNullOrWhiteSpace(request.Text)) throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required."); @@ -111,6 +115,12 @@ namespace SeaHaven.Services.Implementation return WorkOrderCommentProjection.ToDto(comment, authorNames); } + private static void EnsureCommentMutationAllowed(LifecycleStatus? status) + { + if (!WorkOrderBoardMutationRules.CanMutateComments(status)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + } + private static bool IsBoardEditableComment(Comments comment) => string.Equals(comment.CommentType, "General", StringComparison.OrdinalIgnoreCase) && string.Equals(comment.RecordType, "WorkOrder", StringComparison.OrdinalIgnoreCase); diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 5d40998..bcb8872 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -52,11 +52,13 @@ namespace SeaHaven.Services.Implementation int workOrderId, ClaimsPrincipal user, string? actorId, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + WorkOrderMediaCategory? category = null) { WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); // AsNoTracking pre-check so the subsequent AddMediaAsync load is not stale-cached. - await GetMutableWorkOrderForAuthAsync(workOrderId, user, actorId!, cancellationToken); + var workOrder = await GetMutableWorkOrderForAuthAsync(workOrderId, user, actorId!, cancellationToken); + EnsureMediaMutationAllowed(workOrder.LifecycleStatus, category ?? WorkOrderMediaCategory.Extra); } public async Task AddMediaAsync( @@ -71,6 +73,7 @@ namespace SeaHaven.Services.Implementation var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); + EnsureMediaMutationAllowed(workOrder.LifecycleStatus, resolvedCategory); if (resolvedCategory == WorkOrderMediaCategory.Completion) { @@ -167,7 +170,10 @@ namespace SeaHaven.Services.Implementation if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); - var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString(); + var currentCategory = attachment.Category ?? WorkOrderMediaCategory.Extra; + EnsureMediaMutationAllowed(workOrder.LifecycleStatus, currentCategory); + EnsureMediaMutationAllowed(workOrder.LifecycleStatus, category); + var priorCategory = currentCategory.ToString(); if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After) { @@ -238,7 +244,9 @@ namespace SeaHaven.Services.Implementation if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); - var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString(); + var currentCategory = attachment.Category ?? WorkOrderMediaCategory.Extra; + EnsureMediaMutationAllowed(workOrder.LifecycleStatus, currentCategory); + var priorCategory = currentCategory.ToString(); attachment.IsDeleted = true; attachment.DeletionTime = DateTime.UtcNow; attachment.DeleterUserId = actorId; @@ -266,9 +274,6 @@ namespace SeaHaven.Services.Implementation WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder); - if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) - throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); - return workOrder; } @@ -286,12 +291,17 @@ namespace SeaHaven.Services.Implementation WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder); - if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) - throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); - return workOrder; } + private static void EnsureMediaMutationAllowed( + LifecycleStatus? status, + WorkOrderMediaCategory category) + { + if (!WorkOrderBoardMutationRules.CanMutateExtraMedia(status, category)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + } + /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index b4e97cb..bb105eb 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -19,7 +19,8 @@ namespace SeaHaven.Services.Interfaces int workOrderId, ClaimsPrincipal user, string? actorId, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + WorkOrderMediaCategory? category = null); Task AddMediaAsync( int workOrderId, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 4cda7f8..195c8d1 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -54,6 +54,43 @@ public class WorkOrderBoardMutationRulesTests scheduleWeekOnly: true)); } + [Fact] + public void CanPatchBoardField_AllowsOnlyFlagColorWhenCompleted() + { + Assert.True(WorkOrderBoardMutationRules.CanPatchBoardField( + LifecycleStatus.Completed, WorkOrderBoardFieldNames.FlagColor)); + Assert.False(WorkOrderBoardMutationRules.CanPatchBoardField( + LifecycleStatus.Completed, WorkOrderBoardFieldNames.LifecycleStatus)); + Assert.False(WorkOrderBoardMutationRules.CanPatchBoardField( + LifecycleStatus.Completed, WorkOrderBoardFieldNames.SiteCode)); + Assert.False(WorkOrderBoardMutationRules.CanPatchBoardField( + LifecycleStatus.Canceled, WorkOrderBoardFieldNames.FlagColor)); + Assert.True(WorkOrderBoardMutationRules.CanPatchBoardField( + LifecycleStatus.Scheduled, WorkOrderBoardFieldNames.AssignTo)); + } + + [Fact] + public void CanMutateComments_FalseOnlyWhenCanceled() + { + Assert.True(WorkOrderBoardMutationRules.CanMutateComments(LifecycleStatus.Completed)); + Assert.True(WorkOrderBoardMutationRules.CanMutateComments(LifecycleStatus.Scheduled)); + Assert.False(WorkOrderBoardMutationRules.CanMutateComments(LifecycleStatus.Canceled)); + } + + [Fact] + public void CanMutateExtraMedia_AllowsExtraOnCompleted() + { + Assert.True(WorkOrderBoardMutationRules.CanMutateExtraMedia( + LifecycleStatus.Completed, WorkOrderMediaCategory.Extra)); + Assert.True(WorkOrderBoardMutationRules.CanMutateExtraMedia(LifecycleStatus.Completed, null)); + Assert.False(WorkOrderBoardMutationRules.CanMutateExtraMedia( + LifecycleStatus.Completed, WorkOrderMediaCategory.Before)); + Assert.False(WorkOrderBoardMutationRules.CanMutateExtraMedia( + LifecycleStatus.Completed, WorkOrderMediaCategory.After)); + Assert.False(WorkOrderBoardMutationRules.CanMutateExtraMedia( + LifecycleStatus.Canceled, WorkOrderMediaCategory.Extra)); + } + [Fact] public void IsReschedule_WhenPreviousDateExistsAndChanges() { diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs new file mode 100644 index 0000000..9ebd221 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -0,0 +1,363 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderCompletedSelectiveLockTests +{ + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateBoardSut() + { + 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 WorkOrderBoardUpdateService( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(context), + audit); + return (context, service); + } + + private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateMediaSut() + { + 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 WorkOrder CompletedWorkOrder() + => new() + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + + [Fact] + public async Task PatchField_Completed_AllowsFlagColor() + { + var (context, service) = CreateBoardSut(); + var wo = CompletedWorkOrder(); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "#ef4444", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("#EF4444", result.FlagColor); + } + + [Theory] + [InlineData(WorkOrderBoardFieldNames.LifecycleStatus, "Scheduled")] + [InlineData(WorkOrderBoardFieldNames.SiteCode, "BK5")] + [InlineData(WorkOrderBoardFieldNames.AssignTo, "dispatcher-2")] + public async Task PatchField_Completed_BlocksCoreFields(string field, string value) + { + var (context, service) = CreateBoardSut(); + var wo = CompletedWorkOrder(); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = field, + Value = value, + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } + + [Fact] + public async Task PatchField_Canceled_BlocksFlagColor() + { + var (context, service) = CreateBoardSut(); + 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.FlagColor, + Value = "#ef4444", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } + + [Fact] + public async Task UpdateComment_Completed_UpdatesText() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(CompletedWorkOrder()); + context.Users.Add(new ApplicationUser + { + Id = "user-author", + UserName = "author", + FirstName = "Alice", + LastName = "Dispatcher" + }); + var comment = new Comments + { + WorkerOrderId = 1, + UserId = "user-author", + Commenttext = "Original", + CommentType = "General", + RecordType = "WorkOrder", + 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)); + + var result = await service.UpdateCommentAsync( + 1, + comment.Id, + new WorkOrderCommentCreateDto { Text = "Follow-up" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), + "user-author"); + + Assert.Equal("Follow-up", result.Text); + } + + [Fact] + public async Task AddComment_Completed_AddsText() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(CompletedWorkOrder()); + context.Users.Add(new ApplicationUser + { + Id = "user-author", + UserName = "author", + FirstName = "Alice", + LastName = "Dispatcher" + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderCommentService( + new WorkOrderDetailDataService(context), + new CommentDataService(context), + new UserDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.AddCommentAsync( + 1, + new WorkOrderCommentCreateDto { Text = "Follow-up" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), + "user-author"); + + Assert.Equal("Follow-up", result.Text); + Assert.Equal("user-author", result.AuthorId); + } + + [Fact] + public async Task AddComment_Canceled_ThrowsReadOnly() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderCommentService( + new WorkOrderDetailDataService(context), + new CommentDataService(context), + new UserDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + + var ex = await Assert.ThrowsAsync(() => + service.AddCommentAsync( + 1, + new WorkOrderCommentCreateDto { Text = "Should not persist" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"), + "user-author")); + + Assert.Equal("ReadOnly", ex.Code); + Assert.Empty(context.Comments); + } + + [Fact] + public async Task AddMedia_Completed_AllowsExtra() + { + var (context, service) = CreateMediaSut(); + context.workOrders.Add(CompletedWorkOrder()); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Extra, + "https://example.com/extra.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1"); + + Assert.True(media.Id > 0); + Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); + } + + [Fact] + public async Task AddMedia_Completed_BlocksBefore() + { + var (context, service) = CreateMediaSut(); + context.workOrders.Add(CompletedWorkOrder()); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Before, + "https://example.com/before.jpg", + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } + + [Fact] + public async Task UpdateMediaCategory_Completed_KeepsExtra() + { + var (context, service) = CreateMediaSut(); + var wo = CompletedWorkOrder(); + 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.Extra, + ToVersion(wo), + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1"); + + Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); + } + + [Fact] + public async Task UpdateMediaCategory_Completed_BlocksMoveToBefore() + { + var (context, service) = CreateMediaSut(); + var wo = CompletedWorkOrder(); + 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 ex = await Assert.ThrowsAsync(() => + service.UpdateMediaCategoryAsync( + 1, + 10, + WorkOrderMediaCategory.Before, + ToVersion(wo), + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } + + [Fact] + public async Task DeleteMedia_Completed_AllowsExtra() + { + var (context, service) = CreateMediaSut(); + var wo = CompletedWorkOrder(); + 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(); + + await service.DeleteMediaAsync( + 1, + 10, + ToVersion(wo), + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1"); + + Assert.True(context.workOrderAttachments.Single().IsDeleted); + } + + [Fact] + public async Task AddMedia_Canceled_BlocksExtra() + { + var (context, service) = CreateMediaSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + 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/extra.pdf", + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index af241d4..1586a83 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1017,7 +1017,7 @@ public class WorkOrderMediaServiceTests context.workOrders.Add(new WorkOrder { Id = 1, - LifecycleStatus = LifecycleStatus.Completed, + LifecycleStatus = LifecycleStatus.Canceled, RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.workOrderAttachments.Add(new WorkOrderAttachments @@ -1566,7 +1566,7 @@ public class WorkOrderMediaServiceTests await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"); var wo = await context.workOrders.SingleAsync(w => w.Id == 1); - wo.LifecycleStatus = LifecycleStatus.Completed; + wo.LifecycleStatus = LifecycleStatus.Canceled; await context.SaveChangesAsync(); context.ChangeTracker.Clear();