feat(work-orders): board completedDate + media categorize contract

Expose completedDate on PATCH /workorders/{id}/board so CompDoc can leave legacy EditWorkorder. Allow optional media category on upload, PATCH category afterward, and enforce JPG/PNG/MP4/MOV allowlist (SH-116).
This commit is contained in:
Alexandre Brandizzi 2026-07-31 11:58:22 -03:00 • committed by Arthur Bassi
parent a7b1f3bc40
commit d073a503d1
14 changed files with 280 additions and 13 deletions

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single /// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 46 routes /// duplication is collapsed here, so this is the distinct action-relative contract. 47 routes
/// come from 44 actions (Editworkorder and GetWorkorderById each bind two routes). /// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes).
/// </summary> /// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{ {
@ -68,6 +68,7 @@ public class WorkOrderRouteContractTests
"GET {id:int}/media", "GET {id:int}/media",
"PATCH {id:int}/board", "PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}", "PATCH {id:int}/comments/{commentId:int}",
"PATCH {id:int}/media/{mediaId:int}",
"POST AddChecklistItem", "POST AddChecklistItem",
"POST AddComment", "POST AddComment",
"POST AddCommentJson", "POST AddCommentJson",

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions; using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims; using System.Security.Claims;
@ -40,7 +41,7 @@ namespace Api.SeaHavenIndustries.Controllers
[RequestSizeLimit(30_000_000)] [RequestSizeLimit(30_000_000)]
public async Task<IActionResult> AddMedia( public async Task<IActionResult> AddMedia(
int id, int id,
[FromForm] WorkOrderMediaCategory category, [FromForm] WorkOrderMediaCategory? category,
[FromForm] IFormFile file) [FromForm] IFormFile file)
{ {
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
@ -48,6 +49,7 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
WorkOrderMediaFileRules.EnsureAllowed(file);
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file); var fileUrl = await _fileStorage.SaveFileAsync(file);
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
@ -63,6 +65,28 @@ namespace Api.SeaHavenIndustries.Controllers
} }
} }
[HttpPatch("{id:int}/media/{mediaId:int}")]
public async Task<IActionResult> UpdateMediaCategory(
int id,
int mediaId,
[FromForm] WorkOrderMediaCategory category)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var media = await _workOrderMediaService.UpdateMediaCategoryAsync(id, mediaId, category, actorId);
return Ok(media);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpDelete("{id:int}/media/{mediaId:int}")] [HttpDelete("{id:int}/media/{mediaId:int}")]
public async Task<IActionResult> DeleteMedia(int id, int mediaId) public async Task<IActionResult> DeleteMedia(int id, int mediaId)
{ {

View file

@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNotes, w.ServiceNotes,
w.ExtraServices, w.ExtraServices,
w.DocStatus, w.DocStatus,
w.CompletedDate,
w.FlagColor, w.FlagColor,
w.PrimaryDispatchId, w.PrimaryDispatchId,
w.RowVersion, w.RowVersion,
@ -116,6 +117,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNotes, w.ServiceNotes,
w.ExtraServices, w.ExtraServices,
w.DocStatus, w.DocStatus,
w.CompletedDate,
w.FlagColor, w.FlagColor,
w.PrimaryDispatchId, w.PrimaryDispatchId,
w.RowVersion, w.RowVersion,

View file

@ -46,6 +46,7 @@ namespace SeaHaven.DataServices.Interfaces
string? ServiceNotes, string? ServiceNotes,
string? ExtraServices, string? ExtraServices,
DocStatus? DocStatus, DocStatus? DocStatus,
DateTime? CompletedDate,
string? FlagColor, string? FlagColor,
int? PrimaryDispatchId, int? PrimaryDispatchId,
byte[]? RowVersion, byte[]? RowVersion,

View file

@ -56,6 +56,7 @@ namespace SeaHaven.Services.DTOs
public string? ServiceNotes { get; set; } public string? ServiceNotes { get; set; }
public List<string>? ExtraServices { get; set; } public List<string>? ExtraServices { get; set; }
public DocStatus? DocStatus { get; set; } public DocStatus? DocStatus { get; set; }
public DateTime? CompletedDate { get; set; }
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary> /// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
public string? FlagColor { get; set; } public string? FlagColor { get; set; }
public int? PrimaryDispatchId { get; set; } public int? PrimaryDispatchId { get; set; }

View file

@ -19,7 +19,6 @@ namespace SeaHaven.Services.DTOs
public string? SubTrade { get; set; } public string? SubTrade { get; set; }
public DateOnly? OriginalWeek { get; set; } public DateOnly? OriginalWeek { get; set; }
public DateOnly? OriginalDate { get; set; } public DateOnly? OriginalDate { get; set; }
public DateTime? CompletedDate { get; set; }
public string? VendorNotes { get; set; } public string? VendorNotes { get; set; }
} }

View file

@ -15,6 +15,7 @@ namespace SeaHaven.Services.Helpers
public const string ApptDate = "apptDate"; public const string ApptDate = "apptDate";
public const string ApptTime = "apptTime"; public const string ApptTime = "apptTime";
public const string DocStatus = "docStatus"; public const string DocStatus = "docStatus";
public const string CompletedDate = "completedDate";
public const string Pm = "pm"; public const string Pm = "pm";
public const string ServiceNotes = "serviceNotes"; public const string ServiceNotes = "serviceNotes";
public const string ExtraServices = "extraServices"; public const string ExtraServices = "extraServices";
@ -43,6 +44,7 @@ namespace SeaHaven.Services.Helpers
ApptDate, ApptDate,
ApptTime, ApptTime,
DocStatus, DocStatus,
CompletedDate,
Pm, Pm,
ServiceNotes, ServiceNotes,
ExtraServices, ExtraServices,
@ -83,6 +85,7 @@ namespace SeaHaven.Services.Helpers
ApptDate => "ApptDate", ApptDate => "ApptDate",
ApptTime => "ApptTime", ApptTime => "ApptTime",
DocStatus => "DocStatus", DocStatus => "DocStatus",
CompletedDate => "CompletedDate",
Pm => "Trade", Pm => "Trade",
ServiceNotes => "ServiceNotes", ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices", ExtraServices => "ExtraServices",

View file

@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Http;
namespace SeaHaven.Services.Helpers
{
/// <summary>SH-116 media type allowlist for board work-order uploads.</summary>
public static class WorkOrderMediaFileRules
{
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg",
"image/png",
"video/mp4",
"video/quicktime"
};
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg",
".jpeg",
".png",
".mp4",
".mov"
};
public static bool IsAllowed(IFormFile file)
{
if (file == null)
return false;
var contentTypeOk = !string.IsNullOrWhiteSpace(file.ContentType)
&& AllowedContentTypes.Contains(file.ContentType.Trim());
var extension = Path.GetExtension(file.FileName ?? "");
var extensionOk = !string.IsNullOrWhiteSpace(extension)
&& AllowedExtensions.Contains(extension);
// Accept when either MIME or extension matches the advertised contract (JPG/PNG/MP4/MOV).
return contentTypeOk || extensionOk;
}
public static void EnsureAllowed(IFormFile file)
{
if (!IsAllowed(file))
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Supported media types are JPG, PNG, MP4, and MOV.");
}
}
}
}

View file

@ -113,6 +113,7 @@ namespace SeaHaven.Services.Implementation
ServiceNotes = row.ServiceNotes, ServiceNotes = row.ServiceNotes,
ExtraServices = ParseExtraServices(row.ExtraServices), ExtraServices = ParseExtraServices(row.ExtraServices),
DocStatus = row.DocStatus, DocStatus = row.DocStatus,
CompletedDate = row.CompletedDate,
FlagColor = row.FlagColor, FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId, PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion, RowVersion = row.RowVersion,

View file

@ -153,6 +153,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) }, WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.CompletedDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.CompletedDate = v, () => workOrder.CompletedDate) },
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },

View file

@ -34,10 +34,12 @@ namespace SeaHaven.Services.Implementation
public async Task<WorkOrderMediaFileDto> AddMediaAsync( public async Task<WorkOrderMediaFileDto> AddMediaAsync(
int workOrderId, int workOrderId,
WorkOrderMediaCategory category, WorkOrderMediaCategory? category,
string fileUrl, string fileUrl,
string? actorId) string? actorId)
{ {
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (!await _detailData.ExistsAsync(workOrderId)) if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
@ -48,34 +50,34 @@ namespace SeaHaven.Services.Implementation
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
if (category == WorkOrderMediaCategory.Completion) if (resolvedCategory == WorkOrderMediaCategory.Completion)
{ {
throw new WorkOrderBoardValidationException( throw new WorkOrderBoardValidationException(
"UseCompletionDocEndpoint", "UseCompletionDocEndpoint",
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
} }
if (category == WorkOrderMediaCategory.Before) if (resolvedCategory == WorkOrderMediaCategory.Before)
{ {
workOrder.BeforPhotoAttachment = fileUrl; workOrder.BeforPhotoAttachment = fileUrl;
await _mediaData.SaveAsync(CancellationToken.None); await _mediaData.SaveAsync(CancellationToken.None);
return new WorkOrderMediaFileDto return new WorkOrderMediaFileDto
{ {
Id = -1, Id = -1,
Category = category, Category = resolvedCategory,
Url = fileUrl, Url = fileUrl,
IsLegacy = true IsLegacy = true
}; };
} }
if (category == WorkOrderMediaCategory.After) if (resolvedCategory == WorkOrderMediaCategory.After)
{ {
workOrder.AfterPhotoAttachment = fileUrl; workOrder.AfterPhotoAttachment = fileUrl;
await _mediaData.SaveAsync(CancellationToken.None); await _mediaData.SaveAsync(CancellationToken.None);
return new WorkOrderMediaFileDto return new WorkOrderMediaFileDto
{ {
Id = -2, Id = -2,
Category = category, Category = resolvedCategory,
Url = fileUrl, Url = fileUrl,
IsLegacy = true IsLegacy = true
}; };
@ -85,7 +87,7 @@ namespace SeaHaven.Services.Implementation
{ {
WorkorderId = workOrderId, WorkorderId = workOrderId,
Attachments = fileUrl, Attachments = fileUrl,
Category = category, Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow, CreatedDate = DateTime.UtcNow,
createdby = actorId createdby = actorId
}; };
@ -96,13 +98,78 @@ namespace SeaHaven.Services.Implementation
return new WorkOrderMediaFileDto return new WorkOrderMediaFileDto
{ {
Id = attachment.Id, Id = attachment.Id,
Category = category, Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
Url = fileUrl, Url = fileUrl,
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
IsLegacy = false IsLegacy = false
}; };
} }
public async Task<WorkOrderMediaFileDto> UpdateMediaCategoryAsync(
int workOrderId,
int mediaId,
WorkOrderMediaCategory category,
string? actorId)
{
if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint.");
if (category == WorkOrderMediaCategory.Completion)
{
throw new WorkOrderBoardValidationException(
"UseCompletionDocEndpoint",
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
}
if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None);
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After)
{
var url = attachment.Attachments ?? "";
if (category == WorkOrderMediaCategory.Before)
workOrder.BeforPhotoAttachment = url;
else
workOrder.AfterPhotoAttachment = url;
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
await _mediaData.SaveAsync(CancellationToken.None);
return new WorkOrderMediaFileDto
{
Id = category == WorkOrderMediaCategory.Before ? -1 : -2,
Category = category,
Url = url,
IsLegacy = true
};
}
attachment.Category = category;
await _mediaData.SaveAsync(CancellationToken.None);
return new WorkOrderMediaFileDto
{
Id = attachment.Id,
Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
Url = attachment.Attachments ?? "",
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
IsLegacy = false
};
}
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId) public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId)
{ {
if (mediaId <= 0) if (mediaId <= 0)

View file

@ -6,7 +6,16 @@ namespace SeaHaven.Services.Interfaces
public interface IWorkOrderMediaService public interface IWorkOrderMediaService
{ {
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId); Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); Task<WorkOrderMediaFileDto> AddMediaAsync(
int workOrderId,
WorkOrderMediaCategory? category,
string fileUrl,
string? actorId);
Task<WorkOrderMediaFileDto> UpdateMediaCategoryAsync(
int workOrderId,
int mediaId,
WorkOrderMediaCategory category,
string? actorId);
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
} }
} }

View file

@ -798,4 +798,59 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("VendorNotFound", ex.Code); Assert.Equal("VendorNotFound", ex.Code);
} }
[Fact]
public async Task PatchField_UpdatesCompletedDate()
{
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.WorkOrderContacts.Add(new WorkOrderContacts { Id = 1, WorkorderId = 1, ContactId = 50 });
context.workOrderCategories.Add(new WorkOrderCategories { Id = 1, WorkorderId = 1, CategoryId = 60 });
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.CompletedDate,
Value = "2026-07-20",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 7, 20), result.CompletedDate);
Assert.Equal(new DateTime(2026, 7, 20), context.workOrders.Single().CompletedDate);
Assert.Equal(1, await context.WorkOrderContacts.CountAsync());
Assert.Equal(1, await context.workOrderCategories.CountAsync());
Assert.Equal(50, context.WorkOrderContacts.Single().ContactId);
Assert.Equal(60, context.workOrderCategories.Single().CategoryId);
}
[Fact]
public async Task PatchField_ClearsCompletedDate_WhenValueEmpty()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
CompletedDate = new DateTime(2026, 7, 1),
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.CompletedDate,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(result.CompletedDate);
Assert.Null(context.workOrders.Single().CompletedDate);
}
} }

View file

@ -650,4 +650,56 @@ public class WorkOrderMediaServiceTests
Assert.Equal("UseCompletionDocEndpoint", ex.Code); Assert.Equal("UseCompletionDocEndpoint", ex.Code);
} }
[Fact]
public async Task AddMedia_WithoutCategory_PersistsAsUncategorizedExtra()
{
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
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", "actor-1");
Assert.True(media.Id > 0);
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
Assert.Null(context.workOrderAttachments.Single().Category);
}
[Fact]
public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment()
{
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.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = null
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
var media = await service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, "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);
}
} }