mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
The rejection message now lists every type the media allowlist accepts, and a test fails if the message and the allowlist drift apart.
2648 lines
95 KiB
C#
2648 lines
95 KiB
C#
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<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_SurfacesSeverityFromBoardRow()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "10000000001",
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
WorkOrderType = WorkOrderType.Emergency,
|
|
Severity = "2",
|
|
ScheduledDate = DateTime.UtcNow.AddDays(1),
|
|
SiteCode = "BK5",
|
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.Equal(2, detail!.Info.Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetail_InactivePrimaryDispatch_OmitsVendorAndMapsStatus()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
|
context.Dispatches.Add(new Dispatch
|
|
{
|
|
Id = 10,
|
|
VendorId = 1,
|
|
WorkOrderId = 1,
|
|
Status = "Refused",
|
|
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
|
|
});
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "10000000001",
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
ScheduledDate = DateTime.UtcNow.AddDays(1),
|
|
PrimaryDispatchId = 10,
|
|
SiteCode = "BK5",
|
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.Null(detail!.Info.VendorId);
|
|
Assert.Null(detail.Info.VendorName);
|
|
Assert.Equal("Refused", detail.Info.PrimaryDispatchStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetail_LiveDispatchWithEmptyCompanyName_KeepsVendorIdAndMapsStatus()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
|
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "" });
|
|
context.Dispatches.Add(new Dispatch
|
|
{
|
|
Id = 10,
|
|
VendorId = 1,
|
|
WorkOrderId = 1,
|
|
Status = "Sent",
|
|
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
|
|
});
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "10000000001",
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
ScheduledDate = DateTime.UtcNow.AddDays(1),
|
|
PrimaryDispatchId = 10,
|
|
SiteCode = "BK5",
|
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.Equal(1, detail!.Info.VendorId);
|
|
Assert.Equal("Sent", detail.Info.PrimaryDispatchStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetail_ReturnsAdditionalContactsFromBoardRow()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "10000000218",
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
ScheduledDate = DateTime.UtcNow.AddDays(1),
|
|
AdditionalContacts =
|
|
"[{\"name\":\"Backup Lead\",\"phone\":\"+1 555-0101\",\"notes\":\"After hours\"},{\"name\":\"Security Desk\",\"phone\":\"+1 555-0102\"}]",
|
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.NotNull(detail!.Info.AdditionalContacts);
|
|
Assert.Equal(2, detail.Info.AdditionalContacts!.Count);
|
|
Assert.Equal("Backup Lead", detail.Info.AdditionalContacts[0].Name);
|
|
Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone);
|
|
Assert.Equal("After hours", detail.Info.AdditionalContacts[0].Notes);
|
|
Assert.Equal("Security Desk", detail.Info.AdditionalContacts[1].Name);
|
|
Assert.Equal("+1 555-0102", detail.Info.AdditionalContacts[1].Phone);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetail_ReflectsPatchedAdditionalContacts()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
var wo = new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "10000000219",
|
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
|
AdditionalContacts = "[{\"name\":\"Old Backup\",\"phone\":\"+1 555-0000\"}]",
|
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
|
};
|
|
context.workOrders.Add(wo);
|
|
await context.SaveChangesAsync();
|
|
|
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
|
var boardData = new WorkOrderBoardDataService(context);
|
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
|
|
|
await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
|
{
|
|
Field = WorkOrderBoardFieldNames.AdditionalContacts,
|
|
Value = "[{\"name\":\"Jane Backup\",\"phone\":\"+1 555-0101\",\"notes\":\"Call first\"}]",
|
|
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
|
|
}, "actor-1");
|
|
|
|
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.Single(detail!.Info.AdditionalContacts!);
|
|
Assert.Equal("Jane Backup", detail.Info.AdditionalContacts![0].Name);
|
|
Assert.Equal("+1 555-0101", detail.Info.AdditionalContacts[0].Phone);
|
|
Assert.Equal("Call first", detail.Info.AdditionalContacts[0].Notes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
|
|
{
|
|
var (context, service) = CreateSut();
|
|
const string oldUserId = "a1000001-0001-4000-8000-000000000002";
|
|
const string newUserId = "b5e356d5-d926-4f92-8673-ee13cddeff0f";
|
|
|
|
context.Users.AddRange(
|
|
new ApplicationUser
|
|
{
|
|
Id = oldUserId,
|
|
UserName = "alice",
|
|
FirstName = "Alice",
|
|
LastName = "Dispatcher"
|
|
},
|
|
new ApplicationUser
|
|
{
|
|
Id = newUserId,
|
|
UserName = "bob",
|
|
FirstName = "Bob",
|
|
LastName = "Tech"
|
|
});
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = 1,
|
|
InternalWONumber = "00000000001",
|
|
LifecycleStatus = LifecycleStatus.Pending,
|
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
|
});
|
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = 1,
|
|
UserId = "actor-1",
|
|
EventType = "Manual",
|
|
Action = "AssignmentChanged",
|
|
FieldName = "AssignTo",
|
|
OldValue = oldUserId,
|
|
NewValue = newUserId,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = 1,
|
|
UserId = "actor-1",
|
|
EventType = "Manual",
|
|
Action = "AssignmentChanged",
|
|
FieldName = "AssignTo",
|
|
OldValue = newUserId,
|
|
NewValue = "",
|
|
CreatedAt = DateTime.UtcNow.AddMinutes(1)
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var audit = await service.GetAuditAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
|
|
|
|
Assert.NotNull(audit);
|
|
Assert.Equal(2, audit!.Count);
|
|
var unassigned = audit.Single(a => a.NewValue == "Unassigned");
|
|
Assert.Equal("Bob Tech", unassigned.OldValue);
|
|
var reassigned = audit.Single(a => a.NewValue == "Bob Tech");
|
|
Assert.Equal("Alice Dispatcher", reassigned.OldValue);
|
|
}
|
|
}
|
|
|
|
public class WorkOrderCompletionServiceTests
|
|
{
|
|
private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut()
|
|
{
|
|
var options = new DbContextOptionsBuilder<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(
|
|
IFileStoragePort? fileStorage = null)
|
|
{
|
|
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,
|
|
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<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_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<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.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<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 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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<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");
|
|
}
|
|
|
|
[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<WorkOrderBoardValidationException>(() =>
|
|
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<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.Canceled;
|
|
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);
|
|
}
|
|
|
|
[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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<OperationCanceledException>(() =>
|
|
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("<html>not a pdf</html>");
|
|
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<byte>(), "completion.pdf", "application/pdf")));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureAllowed_RejectedFile_ThrowsUnsupportedMediaType()
|
|
{
|
|
var html = Encoding.UTF8.GetBytes("<html>not a pdf</html>");
|
|
var ex = Assert.Throws<WorkOrderBoardValidationException>(
|
|
() => 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("<document />");
|
|
}
|
|
|
|
return stream.ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType()
|
|
{
|
|
const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static;
|
|
var allowedTypes = (HashSet<string>?)typeof(WorkOrderMediaFileRules)
|
|
.GetField("AllowedContentTypes", privateStatic)?.GetValue(null);
|
|
var extensionsByType = (Dictionary<string, HashSet<string>>?)typeof(WorkOrderMediaFileRules)
|
|
.GetField("ExtensionsByContentType", privateStatic)?.GetValue(null);
|
|
Assert.NotNull(allowedTypes);
|
|
Assert.NotNull(extensionsByType);
|
|
Assert.NotEmpty(allowedTypes);
|
|
|
|
var ex = Assert.Throws<WorkOrderBoardValidationException>(
|
|
() => 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("<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")));
|
|
}
|
|
|
|
// 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("<html>not an image</html>");
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
WorkOrderMediaFileRules.EnsureAllowed(
|
|
FormFile(
|
|
DocxBytes(includeWordEntry: false),
|
|
"report.docx",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
|
WorkOrderMediaCategory.Extra));
|
|
|
|
Assert.Equal("UnsupportedMediaType", ex.Code);
|
|
}
|
|
}
|