Merge branch 'dev' into fix/SH-337-completion-doc-allowlist

This commit is contained in:
Alexandre Brandizzi 2026-09-10 14:38:06 -03:00 • committed by GitHub
commit af6faf77e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1349 additions and 115 deletions

View file

@ -330,17 +330,25 @@ jobs:
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$expected" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
if [ "$current" != "$expected" ]; then
echo "Environment became Ready on version $current, not the expected $expected." >&2
exit 1
fi
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
echo "Expected application version is Ready and healthy."
exit 0
fi
echo "Environment became Ready without activating expected version $expected." >&2
exit 1
# The expected version IS active. Elastic Beanstalk reports Ready as
# soon as the rollout finishes, before enhanced health has converged,
# so deciding on the first Ready poll fails a good release on a health
# value that was always going to change. Keep polling; an environment
# that is genuinely unhealthy still fails when the window runs out.
echo "Expected version is active; waiting for health to leave $health."
fi
sleep 15
done
echo "Expected application version did not become Ready within the deployment window." >&2
echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&2
exit 1
- name: Post-deploy smoke
@ -612,16 +620,22 @@ jobs:
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
if [ "$current" != "$prev" ]; then
echo "Rollback reached Ready on version $current, not the previous $prev." >&2
exit 1
fi
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
echo "Application version restore complete; previous code is Ready and healthy."
exit 0
fi
echo "Rollback reached Ready in an unexpected version/health state." >&2
exit 1
# Same convergence gap as the release check above: the previous version
# is back, health has not settled yet, and reporting a failed rollback
# here hides the fact that the restore itself worked.
echo "Previous version is active; waiting for health to leave $health."
fi
sleep 15
done
echo "Environment did not return to Ready within rollback window." >&2
echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2
exit 1
deploy-staging:
@ -724,17 +738,25 @@ jobs:
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$expected" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
if [ "$current" != "$expected" ]; then
echo "Environment became Ready on version $current, not the expected $expected." >&2
exit 1
fi
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
echo "Expected application version is Ready and healthy."
exit 0
fi
echo "Environment became Ready without activating expected version $expected." >&2
exit 1
# The expected version IS active. Elastic Beanstalk reports Ready as
# soon as the rollout finishes, before enhanced health has converged,
# so deciding on the first Ready poll fails a good release on a health
# value that was always going to change. Keep polling; an environment
# that is genuinely unhealthy still fails when the window runs out.
echo "Expected version is active; waiting for health to leave $health."
fi
sleep 15
done
echo "Expected application version did not become Ready within the deployment window." >&2
echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&2
exit 1
- name: Post-deploy smoke
@ -822,15 +844,21 @@ jobs:
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
if [ "$current" != "$prev" ]; then
echo "Rollback reached Ready on version $current, not the previous $prev." >&2
exit 1
fi
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
echo "Application version restore complete; previous code is Ready and healthy."
exit 0
fi
echo "Rollback reached Ready in an unexpected version/health state." >&2
exit 1
# Same convergence gap as the release check above: the previous version
# is back, health has not settled yet, and reporting a failed rollback
# here hides the fact that the restore itself worked.
echo "Previous version is active; waiting for health to leave $health."
fi
sleep 15
done
echo "Environment did not return to Ready within rollback window." >&2
echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2
exit 1

View file

@ -0,0 +1,56 @@
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Moq;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class FileStorageAdapterTests : IDisposable
{
private readonly string _webRoot = Path.Combine(Path.GetTempPath(), $"shoc-storage-{Guid.NewGuid():N}");
private readonly FileStorageAdapter _adapter;
public FileStorageAdapterTests()
{
Directory.CreateDirectory(_webRoot);
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(candidate => candidate.WebRootPath).Returns(_webRoot);
environment.SetupGet(candidate => candidate.ContentRootPath).Returns(_webRoot);
_adapter = new FileStorageAdapter(environment.Object, new HttpContextAccessor());
}
[Fact]
public void OpenRead_ValidStoredUrl_ReturnsReadableStream()
{
var directory = Path.Combine(_webRoot, "Assets", "Documents");
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, "report.pdf"), "stored");
using var stream = _adapter.OpenRead("https://example.test/Assets/Documents/report.pdf");
using var reader = new StreamReader(Assert.IsAssignableFrom<Stream>(stream));
Assert.Equal("stored", reader.ReadToEnd());
}
[Theory]
[InlineData("https://example.test/Assets/Images/report.pdf")]
[InlineData("https://example.test/Assets/Documents/../secret.txt")]
[InlineData("https://example.test/Assets/Documents/%2e%2e/secret.txt")]
public void OpenRead_UnsafeUrl_ReturnsNull(string fileUrl)
{
Assert.Null(_adapter.OpenRead(fileUrl));
}
[Fact]
public void OpenRead_MissingFile_ReturnsNull()
{
Assert.Null(_adapter.OpenRead("https://example.test/Assets/Documents/missing.pdf"));
}
public void Dispose()
{
if (Directory.Exists(_webRoot))
Directory.Delete(_webRoot, recursive: true);
}
}

View file

@ -0,0 +1,177 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class WorkOrderMediaControllerTests
{
private static WorkOrderMediaController CreateController(
Mock<IWorkOrderMediaService>? service = null,
Mock<IFileStoragePort>? storage = null)
{
var controller = new WorkOrderMediaController(
(service ?? new Mock<IWorkOrderMediaService>()).Object,
(storage ?? new Mock<IFileStoragePort>()).Object);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
return controller;
}
[Fact]
public void AddMedia_HasFiftyMegabyteRequestLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.Single(
method!.CustomAttributes,
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
Assert.Equal(50_000_000L, bytes.Value);
}
[Fact]
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(50_000_001);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("The uploaded file must not exceed 50 MB.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
{
var bytes = new byte[] { 1, 2, 3 };
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "payload.exe")
{
Headers = new HeaderDictionary(),
ContentType = "application/octet-stream"
};
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("UnsupportedMediaType", error.Code);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task GetMediaContent_DeletedMedia_ReturnsNotFound()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service.Setup(candidate => candidate.GetMediaContentAsync(
1, 10, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new WorkOrderBoardValidationException("NotFound", "Media not found."));
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(service, storage);
var result = await controller.GetMediaContent(1, 10, CancellationToken.None);
var response = Assert.IsType<NotFoundObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("NotFound", error.Code);
storage.Verify(candidate => candidate.TryDelete(It.IsAny<string>()), Times.Never);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task DeleteMedia_Success_DoesNotCallTryDelete()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service.Setup(candidate => candidate.DeleteMediaAsync(
1, 10, null, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(service, storage);
var result = await controller.DeleteMedia(1, 10);
Assert.IsType<NoContentResult>(result);
storage.Verify(candidate => candidate.TryDelete(It.IsAny<string>()), Times.Never);
storage.VerifyNoOtherCalls();
}
}
public class WorkOrderCompletionControllerUploadLimitTests
{
[Fact]
public void UploadCompletionDoc_HasFiftyMegabyteRequestLimit()
{
var method = typeof(WorkOrderCompletionController).GetMethod(
nameof(WorkOrderCompletionController.UploadCompletionDoc));
var attribute = Assert.Single(
method!.CustomAttributes,
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
Assert.Equal(50_000_000L, bytes.Value);
}
}
public class WorkOrderMediaServiceCancellationTests
{
[Fact]
public async Task GetMediaContent_ForwardsCancellationTokenToAllDataReads()
{
using var source = new CancellationTokenSource();
var token = source.Token;
var mediaData = new Mock<IWorkOrderMediaDataService>(MockBehavior.Strict);
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token))
.ReturnsAsync(new WorkOrder { Id = 1 });
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, token))
.ReturnsAsync(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.test/Assets/Documents/report.pdf"
});
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage.Setup(candidate => candidate.OpenRead(
"https://example.test/Assets/Documents/report.pdf"))
.Returns(new MemoryStream([1, 2, 3]));
var service = new WorkOrderMediaService(
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "actor-1"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
},
"Test"));
var result = await service.GetMediaContentAsync(1, 10, user, "actor-1", token);
result.Content.Dispose();
mediaData.Verify(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token), Times.Once);
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
mediaData.VerifyNoOtherCalls();
}
}

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// 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
/// duplication is collapsed here, so this is the distinct action-relative contract. 51 routes
/// come from 49 actions (Editworkorder and GetWorkorderById each bind two routes).
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
/// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
@ -67,6 +67,7 @@ public class WorkOrderRouteContractTests
"GET {id:int}/detail",
"GET {id:int}/uplifts",
"GET {id:int}/media",
"GET {id:int}/media/{mediaId:int}/content",
"PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}",
"PATCH {id:int}/media/{mediaId:int}",
@ -168,6 +169,24 @@ public class WorkOrderRouteContractTests
unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints");
actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count,
"the distinct action-relative endpoint count must match the baseline");
actual.Should().NotContain(endpoint =>
endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void WorkOrder_Family_Does_Not_Expose_Documents_Route()
{
ExpectedWorkOrderEndpoints.Should().Contain("GET {id:int}/media/{mediaId:int}/content");
ExpectedWorkOrderEndpoints.Should().NotContain(endpoint =>
endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase));
var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad
&& WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo))
.Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}")
.ToHashSet(StringComparer.Ordinal);
actual.Should().NotContain(endpoint =>
endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase));
}
[Fact]

View file

@ -96,7 +96,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(30_000_000)]
[RequestSizeLimit(50_000_000)]
public async Task<IActionResult> UploadCompletionDoc(
int id,
[FromForm] IFormFile file,

View file

@ -17,6 +17,8 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/workorders")]
public class WorkOrderMediaController : Controller
{
public const long MaxUploadBytes = 50_000_000;
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IFileStoragePort _fileStorage;
@ -50,8 +52,29 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpGet("{id:int}/media/{mediaId:int}/content")]
public async Task<IActionResult> GetMediaContent(int id, int mediaId, CancellationToken cancellationToken)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var content = await _workOrderMediaService.GetMediaContentAsync(
id, mediaId, User, actorId, cancellationToken);
return File(content.Content, content.ContentType, content.FileName);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id:int}/media")]
[RequestSizeLimit(30_000_000)]
[RequestSizeLimit(MaxUploadBytes)]
public async Task<IActionResult> AddMedia(
int id,
[FromForm] WorkOrderMediaCategory? category,
@ -64,7 +87,14 @@ namespace Api.SeaHavenIndustries.Controllers
string? fileUrl = null;
try
{
WorkOrderMediaFileRules.EnsureAllowed(file);
if (file.Length > MaxUploadBytes)
{
throw new WorkOrderBoardValidationException(
"FileTooLarge",
"The uploaded file must not exceed 50 MB.");
}
WorkOrderMediaFileRules.EnsureAllowed(file, category);
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);

View file

@ -46,18 +46,9 @@ namespace Api.SeaHavenIndustries.Infrastructure
try
{
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
if (!TryResolveDocumentPath(fileUrl, out var fullPath))
return false;
var relativePath = uri.AbsolutePath.TrimStart('/');
if (string.IsNullOrWhiteSpace(relativePath)
|| relativePath.Contains("..", StringComparison.Ordinal)
|| !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!System.IO.File.Exists(fullPath))
return false;
@ -70,6 +61,51 @@ namespace Api.SeaHavenIndustries.Infrastructure
}
}
public Stream? OpenRead(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
return null;
try
{
if (!TryResolveDocumentPath(fileUrl, out var fullPath))
return null;
return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null;
}
catch
{
return null;
}
}
private bool TryResolveDocumentPath(string fileUrl, out string fullPath)
{
fullPath = string.Empty;
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
return false;
var relativePath = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/');
if (string.IsNullOrWhiteSpace(relativePath)
|| relativePath.Contains("..", StringComparison.Ordinal)
|| !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var webRoot = Path.GetFullPath(ResolveWebRoot());
var documentsRoot = Path.GetFullPath(Path.Combine(webRoot, "Assets", "Documents"))
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
var candidatePath = Path.GetFullPath(
Path.Combine(webRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
if (!candidatePath.StartsWith(documentsRoot, StringComparison.OrdinalIgnoreCase))
return false;
fullPath = candidatePath;
return true;
}
private string ResolveWebRoot()
=> _webHostEnvironment.WebRootPath
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");

View file

@ -38,6 +38,16 @@ namespace SeaHaven.DataServices.Implementation
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public Task<WorkOrderAttachments?> GetAttachmentForReadAsync(
int mediaId,
int workOrderId,
CancellationToken cancellationToken)
=> _context.workOrderAttachments
.AsNoTracking()
.FirstOrDefaultAsync(
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,
cancellationToken);
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
=> _context.workOrderAttachments.FirstOrDefaultAsync(
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,

View file

@ -15,6 +15,11 @@ namespace SeaHaven.DataServices.Interfaces
int? accountId,
CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetAttachmentForReadAsync(
int mediaId,
int workOrderId,
CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);

View file

@ -94,6 +94,13 @@ namespace SeaHaven.Services.DTOs
public bool IsLegacy { get; set; }
}
public sealed class WorkOrderMediaContentDto
{
public required Stream Content { get; init; }
public required string ContentType { get; init; }
public required string FileName { get; init; }
}
public class WorkOrderCompletionDocUploadDto
{
public string? SignOffName { get; set; }

View file

@ -33,7 +33,7 @@ namespace SeaHaven.Services.Helpers
}
if (requested == LifecycleStatus.Scheduled
&& (!scheduledDate.HasValue || scheduleWeekOnly == true))
&& !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly))
{
throw new WorkOrderBoardValidationException(
"ScheduledRequiresDate",

View file

@ -62,21 +62,7 @@ namespace SeaHaven.Services.Helpers
}
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<BoardFieldChange>();
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
workOrder.LifecycleStatus,
workOrder.ScheduledDate,
workOrder.AssignTo,
workOrder.ScheduleWeekOnly))
return changes;
var old = workOrder.LifecycleStatus?.ToString();
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
return changes;
}
=> WorkOrderDerivedFields.ApplyLifecycleFromSchedule(workOrder);
public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
{

View file

@ -46,7 +46,13 @@ namespace SeaHaven.Services.Helpers
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
/// <summary>
/// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled.
/// Concrete Schedule On: a calendar day is set and the row is not week-only.
/// </summary>
public static bool HasConcreteSchedule(DateTime? scheduledDate, bool? scheduleWeekOnly)
=> scheduledDate.HasValue && scheduleWeekOnly != true;
/// <summary>
/// Incomplete or Pending + concrete scheduled date → Scheduled. Assignee is not required.
/// Week-only targets do not auto-schedule (same as <see cref="WorkOrderDerivedFields.ApplyAutoScheduleIfEligible"/>).
/// </summary>
public static bool ShouldAutoSchedule(
@ -54,10 +60,27 @@ namespace SeaHaven.Services.Helpers
DateTime? scheduledDate,
string? assignTo,
bool? scheduleWeekOnly = null)
=> status == LifecycleStatus.Incomplete
&& scheduleWeekOnly != true
&& scheduledDate.HasValue
&& !string.IsNullOrWhiteSpace(assignTo);
{
_ = assignTo;
return ShouldPromoteToScheduled(status, scheduledDate, scheduleWeekOnly);
}
public static bool ShouldPromoteToScheduled(
LifecycleStatus? status,
DateTime? scheduledDate,
bool? scheduleWeekOnly = null)
=> (status == LifecycleStatus.Incomplete || status == LifecycleStatus.Pending)
&& HasConcreteSchedule(scheduledDate, scheduleWeekOnly);
/// <summary>
/// Scheduled without a concrete date (cleared or week-only) → Pending. Later statuses are ignored.
/// </summary>
public static bool ShouldDemoteFromScheduled(
LifecycleStatus? status,
DateTime? scheduledDate,
bool? scheduleWeekOnly = null)
=> status == LifecycleStatus.Scheduled
&& !HasConcreteSchedule(scheduledDate, scheduleWeekOnly);
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
=> previousDate.HasValue

View file

@ -50,25 +50,50 @@ namespace SeaHaven.Services.Helpers
}
/// <summary>
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
/// Week-only targets do not auto-schedule.
/// Promote Incomplete/Pending to Scheduled, or demote Scheduled to Pending, from Schedule On.
/// Assignee is not required. Week-only is not a concrete date.
/// </summary>
public static List<WorkOrderBoardFieldMutations.BoardFieldChange> ApplyLifecycleFromSchedule(WorkOrder workOrder)
{
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>();
LifecycleStatus? next = null;
if (WorkOrderBoardMutationRules.ShouldPromoteToScheduled(
workOrder.LifecycleStatus,
workOrder.ScheduledDate,
workOrder.ScheduleWeekOnly))
{
next = LifecycleStatus.Scheduled;
}
else if (WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
workOrder.LifecycleStatus,
workOrder.ScheduledDate,
workOrder.ScheduleWeekOnly))
{
next = LifecycleStatus.Pending;
}
if (next == null || workOrder.LifecycleStatus == next)
return changes;
var old = workOrder.LifecycleStatus?.ToString();
workOrder.LifecycleStatus = next;
workOrder.Status = GetLifecycleStatusLabel(next);
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.StatusChanged(
"LifecycleStatus",
old,
next.Value.ToString()));
return changes;
}
/// <summary>
/// Applies schedule-driven lifecycle (promote or demote). True when the row becomes Scheduled.
/// </summary>
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
{
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
return false;
if (workOrder.ScheduleWeekOnly == true)
return false;
if (workOrder.ScheduledDate == null)
return false;
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
return false;
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
return true;
var before = workOrder.LifecycleStatus;
ApplyLifecycleFromSchedule(workOrder);
return before != LifecycleStatus.Scheduled
&& workOrder.LifecycleStatus == LifecycleStatus.Scheduled;
}
/// <summary>

View file

@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Http;
using Data.SeaHavenIndustries.Enums;
using System.IO.Compression;
namespace SeaHaven.Services.Helpers
{
@ -11,7 +13,10 @@ namespace SeaHaven.Services.Helpers
"image/jpg",
"image/png",
"video/mp4",
"video/quicktime"
"video/quicktime",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
};
private static readonly Dictionary<string, HashSet<string>> ExtensionsByContentType =
@ -20,7 +25,11 @@ namespace SeaHaven.Services.Helpers
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" }
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
["application/msword"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".doc" },
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
};
private static string CanonicalContentType(string contentType)
@ -30,7 +39,9 @@ namespace SeaHaven.Services.Helpers
return contentType;
}
public static bool IsAllowed(IFormFile file)
public static bool IsAllowed(
IFormFile file,
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
{
if (file == null || file.Length <= 0)
return false;
@ -40,6 +51,12 @@ namespace SeaHaven.Services.Helpers
return false;
var contentType = CanonicalContentType(declaredType);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (IsDocument(contentType)
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
{
return false;
}
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (string.IsNullOrWhiteSpace(extension)
@ -52,7 +69,7 @@ namespace SeaHaven.Services.Helpers
try
{
using var stream = file.OpenReadStream();
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64);
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 512);
if (headerLength == 0)
return false;
@ -64,6 +81,9 @@ namespace SeaHaven.Services.Helpers
if (read < header.Length)
Array.Resize(ref header, read);
if (IsDocx(contentType))
return IsWordDocumentArchive(stream);
return MatchesSignature(contentType, header);
}
catch
@ -72,13 +92,15 @@ namespace SeaHaven.Services.Helpers
}
}
public static void EnsureAllowed(IFormFile file)
public static void EnsureAllowed(
IFormFile file,
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
{
if (!IsAllowed(file))
if (!IsAllowed(file, category))
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Supported media types are JPG, PNG, MP4, and MOV.");
"Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
}
}
@ -113,9 +135,43 @@ namespace SeaHaven.Services.Helpers
return HasFtypBox(bytes);
}
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
return bytes.Length >= 4 && bytes.AsSpan(0, 4).SequenceEqual("%PDF"u8);
if (contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase))
{
ReadOnlySpan<byte> oleSignature = stackalloc byte[]
{
0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1
};
return bytes.Length >= oleSignature.Length
&& bytes.AsSpan(0, oleSignature.Length).SequenceEqual(oleSignature);
}
return false;
}
private static bool IsDocument(string contentType)
=> contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase)
|| IsDocx(contentType);
private static bool IsDocx(string contentType)
=> contentType.Equals(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
StringComparison.OrdinalIgnoreCase);
private static bool IsWordDocumentArchive(Stream stream)
{
if (!stream.CanSeek)
return false;
stream.Position = 0;
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
return archive.Entries.Any(entry =>
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase));
}
private static bool HasFtypBox(byte[] bytes)
{
if (bytes.Length < 12)

View file

@ -175,6 +175,8 @@ namespace SeaHaven.Services.Implementation
if (request.ScheduledDate.HasValue)
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
changes.AddRange(WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder));
Dispatch? dispatch = null;
if (hasVendorDispatch)
{

View file

@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
@ -503,7 +503,7 @@ namespace SeaHaven.Services.Implementation
return normalized;
}
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
private static List<FieldChange> ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
{
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
if (parsed == null)
@ -516,16 +516,31 @@ namespace SeaHaven.Services.Implementation
"Replace the provisional SH work order number before marking the work order Completed.");
}
if (parsed == LifecycleStatus.Scheduled
&& !WorkOrderBoardMutationRules.HasConcreteSchedule(
workOrder.ScheduledDate,
workOrder.ScheduleWeekOnly))
{
throw new WorkOrderBoardValidationException(
"ScheduledRequiresDate",
"Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
}
var changes = new List<FieldChange>();
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
if (workOrder.LifecycleStatus == parsed)
return FieldChange.Unchanged(auditField);
if (workOrder.LifecycleStatus != parsed)
{
workOrder.LifecycleStatus = parsed;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
workOrder.LifecycleStatus = parsed;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
changes.Add(FieldChange.StatusChanged(auditField, old, parsed.ToString()));
}
return FieldChange.StatusChanged(auditField, old, parsed.ToString());
// why: Incomplete/Pending patched while a concrete date already exists must still promote.
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
private static List<FieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField)
@ -637,6 +652,7 @@ namespace SeaHaven.Services.Implementation
var changes = new List<FieldChange> { change };
if (change.HasChanged)
AppendIsAddOnRecalculation(workOrder, changes);
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
@ -785,21 +801,12 @@ namespace SeaHaven.Services.Implementation
}
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<FieldChange>();
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
workOrder.LifecycleStatus,
workOrder.ScheduledDate,
workOrder.AssignTo,
workOrder.ScheduleWeekOnly))
return changes;
=> WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder)
.Select(ToFieldChange)
.ToList();
var old = workOrder.LifecycleStatus?.ToString();
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
return changes;
}
private static FieldChange ToFieldChange(WorkOrderBoardFieldMutations.BoardFieldChange change)
=> new(change.FieldName, change.OldValue, change.NewValue, change.Action, change.DispatchId, change.HasChanged);
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
=> current != null && current.AsSpan().SequenceEqual(expected);

View file

@ -15,15 +15,18 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderMediaDataService _mediaData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderMediaService(
IWorkOrderMediaDataService mediaData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService)
IWorkOrderAuditService auditService,
IFileStoragePort fileStorage)
{
_mediaData = mediaData;
_detailData = detailData;
_auditService = auditService;
_fileStorage = fileStorage;
}
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
@ -48,6 +51,42 @@ namespace SeaHaven.Services.Implementation
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
}
public async Task<WorkOrderMediaContentDto> GetMediaContentAsync(
int workOrderId,
int mediaId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
if (mediaId <= 0)
throw MediaNotFound();
var workOrder = await GetMutableWorkOrderForAuthAsync(
workOrderId,
user,
actorId!,
cancellationToken);
var attachment = await _mediaData.GetAttachmentForReadAsync(
mediaId,
workOrder.Id,
cancellationToken);
if (attachment == null || string.IsNullOrWhiteSpace(attachment.Attachments))
throw MediaNotFound();
var content = _fileStorage.OpenRead(attachment.Attachments);
if (content == null)
throw MediaNotFound();
var fileName = GetSafeFileName(attachment.Attachments);
return new WorkOrderMediaContentDto
{
Content = content,
ContentType = GetContentType(fileName),
FileName = fileName
};
}
public async Task EnsureCanMutateMediaAsync(
int workOrderId,
ClaimsPrincipal user,
@ -177,6 +216,13 @@ namespace SeaHaven.Services.Implementation
if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After)
{
if (IsDocumentAttachment(attachment.Attachments))
{
throw new WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Documents can only be categorized as Extra or Aveta.");
}
var url = attachment.Attachments ?? "";
if (category == WorkOrderMediaCategory.Before)
workOrder.BeforPhotoAttachment = url;
@ -361,5 +407,43 @@ namespace SeaHaven.Services.Implementation
private static string FormatMediaAuditValue(int? mediaId, string category)
=> mediaId.HasValue ? $"{mediaId.Value}:{category}" : category;
private static WorkOrderBoardValidationException MediaNotFound()
=> new("NotFound", "Media not found.");
private static string GetSafeFileName(string fileUrl)
{
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
return "download";
var fileName = Path.GetFileName(Uri.UnescapeDataString(uri.AbsolutePath));
if (fileName.Length > 37
&& fileName[36] == '_'
&& Guid.TryParse(fileName[..36], out _))
{
fileName = fileName[37..];
}
return string.IsNullOrWhiteSpace(fileName) ? "download" : Path.GetFileName(fileName);
}
private static string GetContentType(string fileName)
=> Path.GetExtension(fileName).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".mp4" => "video/mp4",
".mov" => "video/quicktime",
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
_ => "application/octet-stream"
};
private static bool IsDocumentAttachment(string? attachment)
=> GetContentType(GetSafeFileName(attachment ?? string.Empty)) is
"application/pdf"
or "application/msword"
or "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
}
}

View file

@ -24,6 +24,12 @@ namespace SeaHaven.Services.Interfaces
/// cannot be resolved or removed; never throws for missing paths.
/// </summary>
bool TryDelete(string fileUrl);
/// <summary>
/// Opens a previously saved file URL for reading. Returns null when the URL or file
/// cannot be safely resolved.
/// </summary>
Stream? OpenRead(string fileUrl);
}
/// <summary>

View file

@ -12,6 +12,13 @@ namespace SeaHaven.Services.Interfaces
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderMediaContentDto> GetMediaContentAsync(
int workOrderId,
int mediaId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
/// <summary>
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
/// </summary>

View file

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
internal sealed class TestFileStoragePort : IFileStoragePort
{
private readonly Func<string, Stream?> _openRead;
public TestFileStoragePort(Func<string, Stream?>? openRead = null)
{
_openRead = openRead ?? (_ => null);
}
public int TryDeleteCalls { get; private set; }
public Task<string> SaveFileAsync(IFormFile file)
=> Task.FromResult("https://example.test/Assets/Documents/saved.bin");
public bool TryDelete(string fileUrl)
{
TryDeleteCalls++;
return true;
}
public Stream? OpenRead(string fileUrl)
=> _openRead(fileUrl);
}

View file

@ -46,6 +46,7 @@ public class WorkOrderBoardConcurrencyTests
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = new DateTime(2027, 6, 25),
PrimaryDispatchId = 10,
RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 }
});

View file

@ -106,6 +106,40 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal("DuplicateWoNumber", ex.Code);
}
[Fact]
public async Task Create_AutoSchedulesWhenDateWithoutAssignee()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
ScheduledDate = new DateTime(2026, 6, 25)
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
}
[Fact]
public async Task Create_PendingWithDate_PromotesToScheduled()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = new DateTime(2026, 6, 25)
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task Create_AutoSchedulesWhenDateAndAssignee()
{
@ -140,6 +174,7 @@ public class WorkOrderBoardCreateServiceTests
Assert.True(result.ScheduleWeekOnly);
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
}
[Fact]
@ -695,7 +730,7 @@ public class WorkOrderBoardCreateServiceTests
{
var (_, service) = CreateSut();
await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
@ -703,6 +738,28 @@ public class WorkOrderBoardCreateServiceTests
LocationId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
}
[Fact]
public async Task Create_WithScheduledLifecycleWeekOnly_Throws()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 7, 15),
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 7, 13),
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
}
[Fact]

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
@ -50,4 +51,33 @@ public class WorkOrderBoardFieldMutationsTests
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), workOrder.ScheduledStart);
Assert.Equal(new DateTime(2026, 6, 25, 11, 0, 0), workOrder.ScheduledEnd);
}
[Fact]
public void ApplyScheduledDate_IncompleteWithoutAssignee_PromotesToScheduled()
{
var workOrder = new WorkOrder { LifecycleStatus = LifecycleStatus.Incomplete };
var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate(
workOrder,
new DateTime(2026, 6, 25));
Assert.Equal(LifecycleStatus.Scheduled, workOrder.LifecycleStatus);
Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged);
}
[Fact]
public void ApplyScheduledDate_ClearsScheduled_DemotesToPending()
{
var workOrder = new WorkOrder
{
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 25)
};
var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, parsed: null);
Assert.Equal(LifecycleStatus.Pending, workOrder.LifecycleStatus);
Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged
&& c.NewValue == LifecycleStatus.Pending.ToString());
}
}

View file

@ -27,12 +27,21 @@ public class WorkOrderBoardMutationRulesTests
}
[Fact]
public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee()
public void ShouldAutoSchedule_WhenIncompleteWithDate_DoesNotRequireAssignee()
{
Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Incomplete,
new DateTime(2026, 6, 25),
"dispatcher-1"));
assignTo: null));
}
[Fact]
public void ShouldAutoSchedule_WhenPendingWithConcreteDate()
{
Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Pending,
new DateTime(2026, 6, 25),
assignTo: null));
}
[Fact]
@ -41,7 +50,7 @@ public class WorkOrderBoardMutationRulesTests
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Scheduled,
new DateTime(2026, 6, 25),
"dispatcher-1"));
assignTo: null));
}
[Fact]
@ -50,10 +59,52 @@ public class WorkOrderBoardMutationRulesTests
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Incomplete,
new DateTime(2026, 6, 25),
"dispatcher-1",
assignTo: null,
scheduleWeekOnly: true));
}
[Fact]
public void ShouldAutoSchedule_FalseWhenInProgress()
{
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.InProgress,
new DateTime(2026, 6, 25),
assignTo: null));
}
[Fact]
public void ShouldDemoteFromScheduled_WhenDateCleared()
{
Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
LifecycleStatus.Scheduled,
scheduledDate: null));
}
[Fact]
public void ShouldDemoteFromScheduled_WhenWeekOnly()
{
Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
LifecycleStatus.Scheduled,
new DateTime(2026, 6, 25),
scheduleWeekOnly: true));
}
[Fact]
public void ShouldDemoteFromScheduled_FalseWhenConcreteDateRemains()
{
Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
LifecycleStatus.Scheduled,
new DateTime(2026, 6, 25)));
}
[Fact]
public void ShouldDemoteFromScheduled_FalseWhenInProgress()
{
Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled(
LifecycleStatus.InProgress,
scheduledDate: null));
}
[Fact]
public void CanPatchBoardField_AllowsOnlyFlagColorWhenCompleted()
{

View file

@ -62,7 +62,6 @@ public class WorkOrderBoardUpdateServiceTests
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
AssignTo = "user-a",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
@ -83,6 +82,261 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
}
[Fact]
public async Task PatchField_PendingWithDate_PromotesToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
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.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_ClearsScheduledDate_DemotesToPending()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 25),
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.ScheduledDate,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
Assert.Null(result.ScheduledDate);
var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
Assert.Contains(audits, a => a.FieldName == "LifecycleStatus" && a.NewValue == LifecycleStatus.Pending.ToString());
}
[Fact]
public async Task PatchField_ScheduleWeekOnly_DemotesScheduledToPending()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 25),
ScheduleWeekOnly = false,
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.ScheduleWeekOnly,
Value = "true",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus);
Assert.True(result.ScheduleWeekOnly);
var audits = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
}
[Fact]
public async Task PatchField_ScheduleWeekOnlyFalse_PromotesPendingToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = new DateTime(2026, 12, 15),
ScheduleWeekOnly = true,
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.ScheduleWeekOnly,
Value = "false",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.False(result.ScheduleWeekOnly);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithoutDate_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
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.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
Assert.Equal(LifecycleStatus.Incomplete, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithWeekOnly_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = true,
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.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ScheduledRequiresDate", ex.Code);
Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusScheduledWithConcreteDate_Persists()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = false,
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.LifecycleStatus,
Value = "Scheduled",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_LifecycleStatusPendingWithConcreteDate_PromotesToScheduled()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = DateTime.UtcNow.Date.AddDays(14),
ScheduleWeekOnly = false,
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.LifecycleStatus,
Value = "Pending",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_InProgress_ClearDate_DoesNotChangeStatus()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.InProgress,
ScheduledDate = new DateTime(2026, 6, 25),
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.ScheduledDate,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.InProgress, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_EnRoute_ClearDate_DoesNotChangeStatus()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.EnRoute,
ScheduledDate = new DateTime(2026, 6, 25),
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.ScheduledDate,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.EnRoute, result.LifecycleStatus);
}
[Fact]
public async Task PatchField_IncrementsRescheduleCount()
{

View file

@ -40,7 +40,8 @@ public class WorkOrderCompletedSelectiveLockTests
var service = new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
audit,
new TestFileStoragePort());
return (context, service);
}

View file

@ -83,19 +83,48 @@ public class WorkOrderDerivedFieldsTests
}
[Fact]
public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled()
public void ApplyAutoSchedule_WhenDateWithoutAssignee_PromotesToScheduled()
{
var wo = new WorkOrder
{
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = new DateTime(2026, 6, 25),
AssignTo = "dispatcher-1"
ScheduledDate = new DateTime(2026, 6, 25)
};
Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
}
[Fact]
public void ApplyLifecycleFromSchedule_PendingWithDate_PromotesToScheduled()
{
var wo = new WorkOrder
{
LifecycleStatus = LifecycleStatus.Pending,
ScheduledDate = new DateTime(2026, 6, 25)
};
var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo);
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
Assert.Single(changes);
Assert.Equal(AuditActionType.StatusChanged, changes[0].Action);
}
[Fact]
public void ApplyLifecycleFromSchedule_ScheduledWithoutDate_DemotesToPending()
{
var wo = new WorkOrder
{
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = null
};
var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo);
Assert.Equal(LifecycleStatus.Pending, wo.LifecycleStatus);
Assert.Single(changes);
Assert.Equal(LifecycleStatus.Pending.ToString(), changes[0].NewValue);
}
[Fact]
public void ApplyAutoSchedule_WeekOnly_DoesNotPromote()
{
@ -103,7 +132,6 @@ public class WorkOrderDerivedFieldsTests
{
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = new DateTime(2026, 6, 25),
AssignTo = "dispatcher-1",
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22)
};
@ -112,13 +140,25 @@ public class WorkOrderDerivedFieldsTests
Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus);
}
[Fact]
public void ApplyLifecycleFromSchedule_InProgress_UnchangedWhenDateCleared()
{
var wo = new WorkOrder
{
LifecycleStatus = LifecycleStatus.InProgress,
ScheduledDate = null
};
Assert.Empty(WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo));
Assert.Equal(LifecycleStatus.InProgress, wo.LifecycleStatus);
}
[Fact]
public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules()
{
var wo = new WorkOrder
{
ScheduledDate = new DateTime(2026, 6, 25),
AssignTo = "dispatcher-1"
ScheduledDate = new DateTime(2026, 6, 25)
};
WorkOrderDerivedFields.SetInitialLifecycleStatus(wo);

View file

@ -175,7 +175,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
return new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
audit,
new TestFileStoragePort());
}
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)

View file

@ -1,5 +1,6 @@
using System.Security.Claims;
using System.Text;
using System.IO.Compression;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
@ -9,6 +10,7 @@ using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
@ -797,7 +799,8 @@ public class WorkOrderCommentServiceTests
public class WorkOrderMediaServiceTests
{
private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut()
private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut(
IFileStoragePort? fileStorage = null)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
@ -808,7 +811,8 @@ public class WorkOrderMediaServiceTests
var service = new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
audit,
fileStorage ?? new TestFileStoragePort());
return (context, service);
}
@ -1424,6 +1428,44 @@ public class WorkOrderMediaServiceTests
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()
{
@ -1730,6 +1772,97 @@ public class WorkOrderMediaServiceTests
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
@ -1813,6 +1946,19 @@ public class WorkOrderMediaFileRulesTests
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 IsAllowed_ValidJpeg_ReturnsTrue()
{
@ -1869,4 +2015,65 @@ public class WorkOrderMediaFileRulesTests
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);
}
}