Merge branch 'dev' into feat/ab/sh-278-vendor-area

This commit is contained in:
Alexandre Brandizzi 2026-09-16 17:00:27 -03:00 • committed by GitHub
commit 3047c2ba59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 486 additions and 13 deletions

View file

@ -0,0 +1,52 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// SH-169 manual transition guard for the board lifecycleStatus PATCH.
/// Incomplete and Scheduled are derived by the server; a direct request may only
/// restate what derivation already produces, never forge a new automatic state.
/// Past Due is a read-model overlay (<see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>),
/// not a <see cref="LifecycleStatus"/> value, so it cannot be requested at all.
/// </summary>
public static class WorkOrderBoardPatchLifecycleRules
{
public const string AutomaticStatusCode = "AutomaticLifecycleStatus";
public static bool IsAutomaticStatus(LifecycleStatus status)
=> status == LifecycleStatus.Incomplete || status == LifecycleStatus.Scheduled;
public static void EnsureRequestAllowed(
LifecycleStatus? current,
LifecycleStatus requested,
DateTime? scheduledDate,
bool? scheduleWeekOnly)
{
if (!IsAutomaticStatus(requested))
return;
if (requested == LifecycleStatus.Scheduled
&& !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly))
{
throw new WorkOrderBoardValidationException(
"ScheduledRequiresDate",
"Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
}
// why: the board client restates the derived status after a schedule patch; that is a no-op, not a forge.
if (current == requested)
return;
if (requested == LifecycleStatus.Scheduled
&& WorkOrderBoardMutationRules.ShouldPromoteToScheduled(current, scheduledDate, scheduleWeekOnly))
{
return;
}
throw new WorkOrderBoardValidationException(
AutomaticStatusCode,
$"{LifecycleStatusMapper.ToFeLabel(requested)} is set automatically and cannot be selected manually.");
}
}
}

View file

@ -32,6 +32,27 @@ namespace SeaHaven.Services.Helpers
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
};
// A browser fills the multipart part's Content-Type from File.type, which mobile
// browsers leave empty (or the client sends octet-stream) when the OS cannot
// classify a picked file. Only then is the type resolved from the extension; the
// category rule, extension pairing, and magic-byte signature still decide.
private static readonly HashSet<string> UndeterminedContentTypes =
new(StringComparer.OrdinalIgnoreCase) { string.Empty, "application/octet-stream" };
private static string? ResolveContentType(string declaredType, string extension)
{
if (!UndeterminedContentTypes.Contains(declaredType))
return AllowedContentTypes.Contains(declaredType) ? declaredType : null;
foreach (var (contentType, extensions) in ExtensionsByContentType)
{
if (extensions.Contains(extension))
return contentType;
}
return null;
}
private static string CanonicalContentType(string contentType)
{
if (contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
@ -47,10 +68,12 @@ namespace SeaHaven.Services.Helpers
return false;
var declaredType = (file.ContentType ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(declaredType) || !AllowedContentTypes.Contains(declaredType))
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var resolvedType = ResolveContentType(declaredType, extension);
if (resolvedType == null)
return false;
var contentType = CanonicalContentType(declaredType);
var contentType = CanonicalContentType(resolvedType);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (IsDocument(contentType)
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
@ -58,7 +81,6 @@ namespace SeaHaven.Services.Helpers
return false;
}
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (string.IsNullOrWhiteSpace(extension)
|| !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions)
|| !allowedExtensions.Contains(extension))

View file

@ -5,6 +5,8 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderNumberNormalizer
{
private const int RequiredLength = 11;
private const string ProvisionalPrefix = "SH";
private const int MinimumProvisionalDigits = 5;
public static bool TryNormalize(string? input, out string normalized, out string? error)
{
@ -17,7 +19,11 @@ namespace SeaHaven.Services.Helpers
return false;
}
var digits = Regex.Replace(input.Trim(), @"\D", "");
var trimmed = input.Trim();
if (WorkOrderNumberRules.IsProvisional(trimmed))
return TryNormalizeProvisional(trimmed, out normalized, out error);
var digits = Regex.Replace(trimmed, @"\D", "");
if (digits.Length == 0)
{
error = "WO number must contain digits.";
@ -33,5 +39,30 @@ namespace SeaHaven.Services.Helpers
normalized = digits.PadLeft(RequiredLength, '0');
return true;
}
// SH-320: an SH-prefixed number is the documented placeholder for a WO still
// awaiting its real APM number; keep the prefix instead of stripping it to digits.
private static bool TryNormalizeProvisional(string trimmed, out string normalized, out string? error)
{
normalized = "";
error = null;
var digits = Regex.Replace(trimmed[ProvisionalPrefix.Length..], @"\D", "");
if (digits.Length == 0)
{
error = "WO number must contain digits.";
return false;
}
var candidate = ProvisionalPrefix + digits.PadLeft(MinimumProvisionalDigits, '0');
if (candidate.Length > RequiredLength)
{
error = $"WO number cannot exceed {RequiredLength} characters.";
return false;
}
normalized = candidate;
return true;
}
}
}

View file

@ -334,6 +334,14 @@ namespace SeaHaven.Services.Implementation
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
// SH-320: a saved real APM number can never be downgraded to an SH placeholder.
if (WorkOrderNumberRules.IsProvisional(normalized)
&& !string.IsNullOrWhiteSpace(workOrder.InternalWONumber)
&& !WorkOrderNumberRules.IsProvisional(workOrder.InternalWONumber))
throw new WorkOrderBoardValidationException(
"WoNumberDowngrade",
"Cannot replace an APM work order number with an internal number.");
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
@ -516,15 +524,11 @@ 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.");
}
WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(
workOrder.LifecycleStatus,
parsed.Value,
workOrder.ScheduledDate,
workOrder.ScheduleWeekOnly);
var changes = new List<FieldChange>();
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;

View file

@ -87,6 +87,24 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal("00000012345", result.WoNumber);
}
[Theory]
[InlineData("SH00001", "SH00001")]
[InlineData("sh00042", "SH00042")]
public async Task Create_WithManualShPlaceholderWoNumber_KeepsShPrefix(string requested, string expected)
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WoNumber = requested,
WorkOrderType = WorkOrderType.PM,
SiteCode = "DAL",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(expected, result.WoNumber);
}
[Fact]
public async Task Create_DuplicateWoNumber_Throws()
{

View file

@ -0,0 +1,171 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
/// <summary>SH-169 / SH-357 / SH-358: automatic lifecycle states cannot be forged through the board PATCH.</summary>
public class WorkOrderBoardPatchLifecycleRulesTests
{
private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 };
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
audit);
return (context, service);
}
private static async Task SeedAsync(
ApplicationDbContext context,
LifecycleStatus status,
DateTime? scheduledDate = null)
{
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = status,
ScheduledDate = scheduledDate,
ScheduleWeekOnly = false,
RowVersion = Version
});
await context.SaveChangesAsync();
}
private static Task<WorkOrderBoardRowDto> PatchStatusAsync(WorkOrderBoardUpdateService service, string value)
=> service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = value,
WorkOrderVersion = Convert.ToBase64String(Version)
}, "actor-1");
[Theory]
[InlineData(LifecycleStatus.Pending, "Incomplete")]
[InlineData(LifecycleStatus.InProgress, "Incomplete")]
[InlineData(LifecycleStatus.EnRoute, "incomplete")]
[InlineData(LifecycleStatus.OnSite, "1")]
[InlineData(LifecycleStatus.Pending, "Open")]
public async Task PatchField_IncompleteRequestFromAnotherState_IsRejectedWithoutChangeOrAudit(
LifecycleStatus current,
string requested)
{
var (context, service) = CreateSut();
await SeedAsync(context, current);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(
() => PatchStatusAsync(service, requested));
Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Theory]
[InlineData(LifecycleStatus.EnRoute)]
[InlineData(LifecycleStatus.OnSite)]
[InlineData(LifecycleStatus.InProgress)]
[InlineData(LifecycleStatus.PendingQuote)]
public async Task PatchField_ScheduledRequestWhenDerivationWouldNotPromote_IsRejected(LifecycleStatus current)
{
var (context, service) = CreateSut();
await SeedAsync(context, current, DateTime.UtcNow.Date.AddDays(7));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(
() => PatchStatusAsync(service, "Scheduled"));
Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Theory]
[InlineData("Past Due")]
[InlineData("PastDue")]
public async Task PatchField_PastDueIsNotARequestableLifecycleStatus(string requested)
{
var (context, service) = CreateSut();
await SeedAsync(context, LifecycleStatus.Pending);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(
() => PatchStatusAsync(service, requested));
Assert.Equal("InvalidValue", ex.Code);
Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
}
[Fact]
public async Task PatchField_RestatingDerivedScheduledStatus_IsANoOp()
{
var (context, service) = CreateSut();
await SeedAsync(context, LifecycleStatus.Scheduled, DateTime.UtcNow.Date.AddDays(7));
var result = await PatchStatusAsync(service, "Scheduled");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Theory]
[InlineData(LifecycleStatus.Incomplete, "Pending")]
[InlineData(LifecycleStatus.Pending, "En Route")]
[InlineData(LifecycleStatus.EnRoute, "On Site")]
[InlineData(LifecycleStatus.OnSite, "In Progress")]
[InlineData(LifecycleStatus.InProgress, "Pending")]
public async Task PatchField_ManualTransition_PersistsAndAuditsActorPreviousAndNew(
LifecycleStatus current,
string requested)
{
var (context, service) = CreateSut();
await SeedAsync(context, current);
var result = await PatchStatusAsync(service, requested);
var expected = LifecycleStatusMapper.ParseLifecycleStatus(requested)!.Value;
Assert.Equal(expected, result.LifecycleStatus);
var audit = Assert.Single(await context.WorkOrderAuditLogs.ToListAsync());
Assert.Equal(AuditActionType.StatusChanged.ToString(), audit.Action);
Assert.Equal("actor-1", audit.UserId);
Assert.Equal(current.ToString(), audit.OldValue);
Assert.Equal(expected.ToString(), audit.NewValue);
Assert.NotEqual(default, audit.CreatedAt);
}
[Theory]
[InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Incomplete, false)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.Incomplete, true)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.Pending, false)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.EnRoute, false)]
[InlineData(LifecycleStatus.InProgress, LifecycleStatus.Scheduled, true)]
[InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Scheduled, false)]
[InlineData(LifecycleStatus.Pending, LifecycleStatus.Scheduled, false)]
public void EnsureRequestAllowed_WithConcreteSchedule(
LifecycleStatus current,
LifecycleStatus requested,
bool rejects)
{
var date = new DateTime(2026, 9, 21);
var ex = Record.Exception(() =>
WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(current, requested, date, false));
if (rejects)
Assert.Equal(
WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode,
Assert.IsType<WorkOrderBoardValidationException>(ex).Code);
else
Assert.Null(ex);
}
}

View file

@ -1158,6 +1158,57 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("00000012345", result.WoNumber);
}
[Fact]
public async Task PatchField_ShPlaceholderReplacesShPlaceholder()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "SH00001",
LifecycleStatus = LifecycleStatus.Incomplete,
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.WoNumber,
Value = "SH00007",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("SH00007", result.WoNumber);
}
[Fact]
public async Task PatchField_RejectsReplacingRealWoNumberWithShPlaceholder()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000012345",
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.WoNumber,
Value = "SH00001",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("WoNumberDowngrade", ex.Code);
var stored = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal("00000012345", stored.InternalWONumber);
}
[Fact]
public async Task PatchField_RejectsDuplicateWoNumber()
{

View file

@ -40,4 +40,30 @@ public class WorkOrderNumberNormalizerTests
Assert.False(ok);
Assert.Contains("11", error);
}
[Theory]
[InlineData("SH00001", "SH00001")]
[InlineData(" sh00001 ", "SH00001")]
[InlineData("SH-00001", "SH00001")]
[InlineData("SH1", "SH00001")]
[InlineData("SH123456789", "SH123456789")]
public void TryNormalize_KeepsShPlaceholderPrefix(string input, string expected)
{
var ok = WorkOrderNumberNormalizer.TryNormalize(input, out var normalized, out var error);
Assert.True(ok);
Assert.Null(error);
Assert.Equal(expected, normalized);
}
[Theory]
[InlineData("SH")]
[InlineData("SH1234567890")]
public void TryNormalize_RejectsInvalidShPlaceholder(string input)
{
var ok = WorkOrderNumberNormalizer.TryNormalize(input, out _, out var error);
Assert.False(ok);
Assert.NotNull(error);
}
}

View file

@ -2004,6 +2004,104 @@ public class WorkOrderMediaFileRulesTests
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_DeclaredMimeMismatchingExtension_StillRejected()
{
// A concrete declared type is authoritative; only an undetermined one falls back to the extension.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic")));
}
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()
{