shoc-backend/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs

1675 lines
59 KiB
C#

using System.Security.Claims;
using System.Text;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderDocStatusPatchTests
{
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
return (context, service);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Theory]
[InlineData("Yes", DocStatus.Yes)]
[InlineData("No", DocStatus.No)]
[InlineData("NotNeeded", DocStatus.NotNeeded)]
public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected)
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DocStatus,
Value = value,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(expected, result.DocStatus);
var audit = await context.WorkOrderAuditLogs.SingleAsync();
Assert.Equal("DocStatus", audit.FieldName);
}
[Fact]
public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Canceled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DocStatus,
Value = "Yes",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
}
public class WorkOrderDetailServiceTests
{
private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var 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_InactivePrimaryDispatch_OmitsVendorAndMapsStatus()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
Status = "Refused",
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
PrimaryDispatchId = 10,
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Null(detail!.Info.VendorId);
Assert.Null(detail.Info.VendorName);
Assert.Equal("Refused", detail.Info.PrimaryDispatchStatus);
}
[Fact]
public async Task GetDetail_LiveDispatchWithEmptyCompanyName_KeepsVendorIdAndMapsStatus()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
Status = "Sent",
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
PrimaryDispatchId = 10,
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(1, detail!.Info.VendorId);
Assert.Equal("Sent", detail.Info.PrimaryDispatchStatus);
}
[Fact]
public async Task GetDetail_ReturnsAdditionalContactsFromBoardRow()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000218",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
AdditionalContacts =
"[{\"name\":\"Backup Lead\",\"phone\":\"+1 555-0101\",\"notes\":\"After hours\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.NotNull(detail!.Info.AdditionalContacts);
Assert.Equal(2, detail.Info.AdditionalContacts!.Count);
Assert.Equal("Backup Lead", detail.Info.AdditionalContacts[0].Name);
Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone);
Assert.Equal("After hours", detail.Info.AdditionalContacts[0].Notes);
Assert.Equal("Security Desk", detail.Info.AdditionalContacts[1].Name);
Assert.Equal("+1 555-0102", detail.Info.AdditionalContacts[1].Phone);
}
[Fact]
public async Task GetDetail_ReflectsPatchedAdditionalContacts()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "10000000219",
LifecycleStatus = LifecycleStatus.Incomplete,
AdditionalContacts = "[{\"name\":\"Old Backup\",\"phone\":\"+1 555-0000\"}]",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardData = new WorkOrderBoardDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.AdditionalContacts,
Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]",
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
}, "actor-1");
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Single(detail!.Info.AdditionalContacts!);
Assert.Equal("Jane Backup", detail.Info.AdditionalContacts![0].Name);
Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone);
Assert.Equal("Call first", detail.Info.AdditionalContacts[0].Notes);
}
[Fact]
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
{
var (context, service) = CreateSut();
const string oldUserId = "a1000001-0001-4000-8000-000000000002";
const string newUserId = "b5e356d5-d926-4f92-8673-ee13cddeff0f";
context.Users.AddRange(
new ApplicationUser
{
Id = oldUserId,
UserName = "alice",
FirstName = "Alice",
LastName = "Dispatcher"
},
new ApplicationUser
{
Id = newUserId,
UserName = "bob",
FirstName = "Bob",
LastName = "Tech"
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Pending,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
UserId = "actor-1",
EventType = "Manual",
Action = "AssignmentChanged",
FieldName = "AssignTo",
OldValue = oldUserId,
NewValue = newUserId,
CreatedAt = DateTime.UtcNow
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
UserId = "actor-1",
EventType = "Manual",
Action = "AssignmentChanged",
FieldName = "AssignTo",
OldValue = newUserId,
NewValue = "",
CreatedAt = DateTime.UtcNow.AddMinutes(1)
});
await context.SaveChangesAsync();
var audit = await service.GetAuditAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(audit);
Assert.Equal(2, audit!.Count);
var unassigned = audit.Single(a => a.NewValue == "Unassigned");
Assert.Equal("Bob Tech", unassigned.OldValue);
var reassigned = audit.Single(a => a.NewValue == "Bob Tech");
Assert.Equal("Alice Dispatcher", reassigned.OldValue);
}
}
public class WorkOrderCompletionServiceTests
{
private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var templateData = new CompletionDocTemplateDataService(context);
var completionData = new WorkOrderCompletionDataService(context);
var detailData = new WorkOrderDetailDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
completionData,
templateData,
detailData,
audit,
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
context.Users.Add(new ApplicationUser
{
Id = "user-abc",
UserName = "bob",
FirstName = "Bob",
LastName = "Tech"
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(
detailData, commentData, userData, 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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
context.Comments.Add(new Comments
{
WorkerOrderId = 1,
Commenter = "Legacy Sync Author",
Commenttext = "Synced note",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(
detailData, commentData, userData, 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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = workOrderId, LifecycleStatus = status });
context.Users.Add(new ApplicationUser
{
Id = authorId,
UserName = "author",
FirstName = "Alice",
LastName = "Dispatcher"
});
var comment = new Comments
{
WorkerOrderId = workOrderId,
UserId = authorId,
Commenttext = "Original",
CommentType = commentType,
RecordType = recordType,
CreatedDate = DateTime.UtcNow
};
context.Comments.Add(comment);
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
2,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
WorkOrderAccountTestHelpers.OrgWideAdmin("user-author"),
"user-author"));
Assert.Equal("NotFound", ex.Code);
}
}
public class WorkOrderMediaServiceTests
{
private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
return (context, service);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
private static ClaimsPrincipal AuthenticatedUser(
string actorId = "actor-1",
string role = "Admin",
int? accountId = null,
bool omitScopeClaims = false)
{
var claims = new List<Claim>
{
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_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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<OperationCanceledException>(() =>
service.GetMediaAsync(1, AuthenticatedUser(), "actor-1", cts.Token));
}
/// <summary>
/// ApplyBaseScope filter: deleted work orders → null / no media disclosure.
/// (Not a cross-tenant test — no tenant key exists yet.)
/// </summary>
[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);
}
/// <summary>
/// ApplyBaseScope filter: template work orders are excluded.
/// </summary>
[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.Completed,
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
WorkOrderMediaCategory.Extra,
"https://example.com/photo.jpg",
AuthenticatedUser("tech-1", "User", accountId: 1),
"tech-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task AddMedia_TechnicianOnAssignedWorkOrder_Succeeds()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 1,
AssignTo = "tech-1",
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var media = await service.AddMediaAsync(
1,
null,
"https://example.com/photo.jpg",
AuthenticatedUser("tech-1", "User", accountId: 1),
"tech-1");
Assert.True(media.Id > 0);
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
}
[Fact]
public async Task DeleteMedia_Technician_ThrowsForbidden()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AccountId = 1,
AssignTo = "tech-1",
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/extra.pdf",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteMediaAsync(
1,
10,
ToVersion(context.workOrders.Single()),
AuthenticatedUser("tech-1", "User", accountId: 1),
"tech-1"));
Assert.Equal("Forbidden", ex.Code);
}
/// <summary>
/// ApplyBaseScope mutation: missing id → NotFound (no disclosure).
/// </summary>
[Fact]
public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound_OutsideBaseScope()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanMutateMediaAsync(99, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
/// <summary>
/// ApplyBaseScope mutation: deleted WO → NotFound.
/// </summary>
[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<WorkOrderBoardValidationException>(() =>
service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
/// <summary>
/// ApplyBaseScope mutation: template WO → NotFound.
/// </summary>
[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<WorkOrderBoardValidationException>(() =>
service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = null
});
await context.SaveChangesAsync();
var media = await service.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1");
Assert.Equal(-1, media.Id);
Assert.Equal(WorkOrderMediaCategory.Before, media.Category);
Assert.Equal("https://example.com/photo.jpg", context.workOrders.Single().BeforPhotoAttachment);
Assert.True(context.workOrderAttachments.Single().IsDeleted);
Assert.Contains(context.WorkOrderAuditLogs, a =>
a.FieldName == "MediaCategory" && a.NewValue == "10:Before");
}
[Fact]
public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var stale = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
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.Completed;
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
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<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(
1,
WorkOrderMediaCategory.Extra,
"https://example.com/photo.jpg",
AuthenticatedUser("tech-1", "User", accountId: 1),
"tech-1"));
Assert.Equal("NotFound", ex.Code);
}
}
public class WorkOrderMediaFileRulesTests
{
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
=> new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
[Fact]
public void IsAllowed_ValidJpeg_ReturnsTrue()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_SpoofedExtension_ReturnsFalse()
{
var html = Encoding.UTF8.GetBytes("<html>not an image</html>");
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_SpoofedMime_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.exe", "image/jpeg")));
}
[Fact]
public void IsAllowed_MimeOrExtensionAlone_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.bin", "application/octet-stream")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(new byte[] { 1, 2, 3 }, "photo.jpg", "image/jpeg")));
}
[Fact]
public void IsAllowed_ValidMp4Ftyp_ReturnsTrue()
{
// size(4) + ftyp(4) + isom(4)
var mp4 = new byte[]
{
0x00, 0x00, 0x00, 0x18,
(byte)'f', (byte)'t', (byte)'y', (byte)'p',
(byte)'i', (byte)'s', (byte)'o', (byte)'m'
};
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(mp4, "clip.mp4", "video/mp4")));
}
[Fact]
public void EnsureAllowed_MalformedBytes_ThrowsUnsupportedMediaType()
{
var ex = Assert.Throws<WorkOrderBoardValidationException>(() =>
WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png")));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
}