From 47022c7761e5f54e7da89f8ef83f5d4979afcbd3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:02:40 -0300 Subject: [PATCH 01/17] feat(work-orders): allow Extra Docs PDF/DOC by category --- .../Helpers/WorkOrderMediaFileRules.cs | 71 ++++++++++++++++-- .../WorkOrderPhase6Tests.cs | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index bc03e6c..4786552 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Http; +using Data.SeaHavenIndustries.Enums; +using System.IO.Compression; namespace SeaHaven.Services.Helpers { @@ -10,7 +12,10 @@ namespace SeaHaven.Services.Helpers "image/jpeg", "image/png", "video/mp4", - "video/quicktime" + "video/quicktime", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; private static readonly Dictionary> ExtensionsByContentType = @@ -19,10 +24,16 @@ namespace SeaHaven.Services.Helpers ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, - ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" } + ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }, + ["application/pdf"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".pdf" }, + ["application/msword"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc" }, + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = + new HashSet(StringComparer.OrdinalIgnoreCase) { ".docx" } }; - public static bool IsAllowed(IFormFile file) + public static bool IsAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { if (file == null || file.Length <= 0) return false; @@ -31,6 +42,13 @@ namespace SeaHaven.Services.Helpers if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType)) return false; + 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) || !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions) @@ -42,7 +60,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; @@ -54,6 +72,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 @@ -62,13 +83,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."); } } @@ -95,9 +118,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 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) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 1ee9a4f..a0f81cd 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -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; @@ -1741,6 +1742,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(""); + } + + return stream.ToArray(); + } + [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { @@ -1790,4 +1804,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(() => + 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(() => + WorkOrderMediaFileRules.EnsureAllowed( + FormFile( + DocxBytes(includeWordEntry: false), + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + WorkOrderMediaCategory.Extra)); + + Assert.Equal("UnsupportedMediaType", ex.Code); + } } From b6b8d2e58f9ca2315a7be169592af8e1dfd8af1a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:06:05 -0300 Subject: [PATCH 02/17] feat(work-orders): cap media uploads at 50MB --- .../WorkOrderMediaControllerTests.cs | 75 +++++++++++++++++++ .../Controllers/WorkOrderMediaController.cs | 13 +++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs new file mode 100644 index 0000000..18d1a31 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -0,0 +1,75 @@ +using Api.SeaHavenIndustries.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderMediaControllerTests +{ + private static WorkOrderMediaController CreateController( + Mock? service = null, + Mock? storage = null) + { + var controller = new WorkOrderMediaController( + (service ?? new Mock()).Object, + (storage ?? new Mock()).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(); + file.SetupGet(candidate => candidate.Length).Returns(50_000_001); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(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(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("UnsupportedMediaType", error.Code); + storage.VerifyNoOtherCalls(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 0867b1a..b9bc629 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -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; @@ -51,7 +53,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("{id:int}/media")] - [RequestSizeLimit(30_000_000)] + [RequestSizeLimit(MaxUploadBytes)] public async Task AddMedia( int id, [FromForm] WorkOrderMediaCategory? category, @@ -64,7 +66,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); From 02df093798382c37d939b0587f05357d8a8484ee Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:07:17 -0300 Subject: [PATCH 03/17] feat(work-orders): align completion-doc size with Extra Docs --- .../WorkOrderMediaControllerTests.cs | 16 ++++++++++++++++ .../Controllers/WorkOrderCompletionController.cs | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 18d1a31..01d88fd 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -73,3 +73,19 @@ public class WorkOrderMediaControllerTests 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); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index bf90361..f2ea1cc 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -95,7 +95,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("{id:int}/completion-doc")] - [RequestSizeLimit(30_000_000)] + [RequestSizeLimit(50_000_000)] public async Task UploadCompletionDoc( int id, [FromForm] IFormFile file, From bb651bb54714d1d0b130a2a60f128b5ff5f99906 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:08:26 -0300 Subject: [PATCH 04/17] feat(work-orders): add file storage OpenRead port --- .../Infrastructure/PortAdapters.cs | 27 +++++++++++++++++++ SeaHaven.Services/Interfaces/IServicePorts.cs | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index adcc357..a3ebbf3 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -70,6 +70,33 @@ namespace Api.SeaHavenIndustries.Infrastructure } } + public Stream? OpenRead(string fileUrl) + { + if (string.IsNullOrWhiteSpace(fileUrl)) + return null; + + try + { + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + return null; + + var relativePath = uri.AbsolutePath.TrimStart('/'); + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath.Contains("..", StringComparison.Ordinal) + || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); + return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null; + } + catch + { + return null; + } + } + private string ResolveWebRoot() => _webHostEnvironment.WebRootPath ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs index 10c1718..c2d00a6 100644 --- a/SeaHaven.Services/Interfaces/IServicePorts.cs +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -24,6 +24,12 @@ namespace SeaHaven.Services.Interfaces /// cannot be resolved or removed; never throws for missing paths. /// bool TryDelete(string fileUrl); + + /// + /// Opens a previously saved file URL for reading. Returns null when the URL or file + /// cannot be safely resolved. + /// + Stream? OpenRead(string fileUrl); } /// From a25fd0bd5d3e5a90cab92104d35644109f480956 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:09:55 -0300 Subject: [PATCH 05/17] feat(work-orders): stream stored WO media safely --- .../FileStorageAdapterTests.cs | 56 +++++++++++++++++++ .../Infrastructure/PortAdapters.cs | 49 +++++++++------- 2 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs diff --git a/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs new file mode 100644 index 0000000..04126b8 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs @@ -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(); + 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)); + + 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); + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index a3ebbf3..8b4aad2 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -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; @@ -77,18 +68,9 @@ namespace Api.SeaHavenIndustries.Infrastructure try { - if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + if (!TryResolveDocumentPath(fileUrl, out var fullPath)) return null; - var relativePath = uri.AbsolutePath.TrimStart('/'); - if (string.IsNullOrWhiteSpace(relativePath) - || relativePath.Contains("..", StringComparison.Ordinal) - || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null; } catch @@ -97,6 +79,33 @@ namespace Api.SeaHavenIndustries.Infrastructure } } + 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"); From b4f2c8b763cc6a9ecc39cef96d3e9c2668299bcc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:19:41 -0300 Subject: [PATCH 06/17] feat(work-orders): authorize WO media content reads --- .../WorkOrderMediaControllerTests.cs | 49 ++++++++++ .../WorkOrderMediaDataService.cs | 10 ++ .../Interfaces/IWorkOrderMediaDataService.cs | 5 + SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 7 ++ .../Implementation/WorkOrderMediaService.cs | 73 +++++++++++++- .../Interfaces/IWorkOrderMediaService.cs | 7 ++ .../TestFileStoragePort.cs | 28 ++++++ .../WorkOrderCompletedSelectiveLockTests.cs | 3 +- ...orkOrderMediaConcurrencyRelationalTests.cs | 3 +- .../WorkOrderPhase6Tests.cs | 98 ++++++++++++++++++- 10 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 SeaHavenIndustries.Tests/TestFileStoragePort.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 01d88fd..8c4f826 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -1,9 +1,14 @@ 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.Helpers; +using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; +using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -89,3 +94,47 @@ public class WorkOrderCompletionControllerUploadLimitTests 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(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(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(MockBehavior.Strict).Object, + new Mock(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(); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index a1b887c..fd05a45 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -38,6 +38,16 @@ namespace SeaHaven.DataServices.Implementation return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); } + public Task GetAttachmentForReadAsync( + int mediaId, + int workOrderId, + CancellationToken cancellationToken) + => _context.workOrderAttachments + .AsNoTracking() + .FirstOrDefaultAsync( + a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, + cancellationToken); + public Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken) => _context.workOrderAttachments.FirstOrDefaultAsync( a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index f8db574..57cbbf3 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -15,6 +15,11 @@ namespace SeaHaven.DataServices.Interfaces int? accountId, CancellationToken cancellationToken); + Task GetAttachmentForReadAsync( + int mediaId, + int workOrderId, + CancellationToken cancellationToken); + Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 9832b01..c16da2e 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -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; } diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index bcb8872..4a94fa1 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -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?> GetMediaAsync( @@ -48,6 +51,42 @@ namespace SeaHaven.Services.Implementation return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); } + public async Task 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, @@ -361,5 +400,37 @@ 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" + }; } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index bb105eb..fd24997 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -12,6 +12,13 @@ namespace SeaHaven.Services.Interfaces string? actorId, CancellationToken cancellationToken = default); + Task GetMediaContentAsync( + int workOrderId, + int mediaId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default); + /// /// Authorizes the caller and validates the work order is mutable before any blob storage write. /// diff --git a/SeaHavenIndustries.Tests/TestFileStoragePort.cs b/SeaHavenIndustries.Tests/TestFileStoragePort.cs new file mode 100644 index 0000000..9d5d44c --- /dev/null +++ b/SeaHavenIndustries.Tests/TestFileStoragePort.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +internal sealed class TestFileStoragePort : IFileStoragePort +{ + private readonly Func _openRead; + + public TestFileStoragePort(Func? openRead = null) + { + _openRead = openRead ?? (_ => null); + } + + public int TryDeleteCalls { get; private set; } + + public Task 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); +} diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index 733c589..a51311c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -40,7 +40,8 @@ public class WorkOrderCompletedSelectiveLockTests var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + new TestFileStoragePort()); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs index b39d3d6..bfde333 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -175,7 +175,8 @@ public class WorkOrderMediaConcurrencyRelationalTests return new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + new TestFileStoragePort()); } private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index a0f81cd..8bf1480 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -10,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; @@ -798,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() .UseInMemoryDatabase(Guid.NewGuid().ToString()) @@ -809,7 +811,8 @@ public class WorkOrderMediaServiceTests var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + fileStorage ?? new TestFileStoragePort()); return (context, service); } @@ -1731,6 +1734,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(() => + 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(() => + 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(() => + service.GetMediaContentAsync( + 1, + 10, + AuthenticatedUser(), + "actor-1", + source.Token)); + } } public class WorkOrderMediaFileRulesTests From 9d8ae5ec24c48a1e7e1bf4f3131cc0452265b30e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:23:52 -0300 Subject: [PATCH 07/17] feat(work-orders): add authorized media content GET --- .../WorkOrderMediaControllerTests.cs | 37 +++++++++++++++++++ .../Controllers/WorkOrderMediaController.cs | 21 +++++++++++ 2 files changed, 58 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 8c4f826..b377b40 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -5,6 +5,7 @@ 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; @@ -77,6 +78,42 @@ public class WorkOrderMediaControllerTests Assert.Equal("UnsupportedMediaType", error.Code); storage.VerifyNoOtherCalls(); } + + [Fact] + public async Task GetMediaContent_DeletedMedia_ReturnsNotFound() + { + var service = new Mock(MockBehavior.Strict); + service.Setup(candidate => candidate.GetMediaContentAsync( + 1, 10, It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new WorkOrderBoardValidationException("NotFound", "Media not found.")); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(service, storage); + + var result = await controller.GetMediaContent(1, 10, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("NotFound", error.Code); + storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteMedia_Success_DoesNotCallTryDelete() + { + var service = new Mock(MockBehavior.Strict); + service.Setup(candidate => candidate.DeleteMediaAsync( + 1, 10, null, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(service, storage); + + var result = await controller.DeleteMedia(1, 10); + + Assert.IsType(result); + storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); + storage.VerifyNoOtherCalls(); + } } public class WorkOrderCompletionControllerUploadLimitTests diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index b9bc629..62f81df 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -52,6 +52,27 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("{id:int}/media/{mediaId:int}/content")] + public async Task 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(MaxUploadBytes)] public async Task AddMedia( From 143d61354719180782b04f18b4c6721cfc745b4d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:25:26 -0300 Subject: [PATCH 08/17] test(work-orders): expect media content route --- .../WorkOrderRouteContractTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 920f0f4..6411337 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -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). /// private static readonly HashSet 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] From 335390f7468a5cc26385087da6bbcb5faa610761 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:19:50 -0300 Subject: [PATCH 09/17] feat(work-orders): derive Scheduled from date without assignee --- .../Helpers/WorkOrderBoardMutationRules.cs | 33 +++++++++-- .../WorkOrderBoardMutationRulesTests.cs | 59 +++++++++++++++++-- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index dde4bfb..e1cec9d 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -46,7 +46,13 @@ namespace SeaHaven.Services.Helpers => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; /// - /// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled. + /// Concrete Schedule On: a calendar day is set and the row is not week-only. + /// + public static bool HasConcreteSchedule(DateTime? scheduledDate, bool? scheduleWeekOnly) + => scheduledDate.HasValue && scheduleWeekOnly != true; + + /// + /// Incomplete or Pending + concrete scheduled date → Scheduled. Assignee is not required. /// Week-only targets do not auto-schedule (same as ). /// 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); + + /// + /// Scheduled without a concrete date (cleared or week-only) → Pending. Later statuses are ignored. + /// + 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 diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 6494004..548a50f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -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() { From e12b3ea54b8cc3574c201205a9f45e006f55164d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:21:14 -0300 Subject: [PATCH 10/17] feat(work-orders): apply schedule lifecycle promote and demote --- .../Helpers/WorkOrderDerivedFields.cs | 57 +++++++++++++------ .../WorkOrderDerivedFieldsTests.cs | 52 +++++++++++++++-- 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index c7ac473..9ef534c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -50,25 +50,50 @@ namespace SeaHaven.Services.Helpers } /// - /// 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. + /// + public static List ApplyLifecycleFromSchedule(WorkOrder workOrder) + { + var changes = new List(); + 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; + } + + /// + /// Applies schedule-driven lifecycle (promote or demote). True when the row becomes Scheduled. /// 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; } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs index 2dd8c6b..eadc960 100644 --- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -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); From ae9122243d4a68037a508ee6832ab05f10f57081 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:22:11 -0300 Subject: [PATCH 11/17] feat(work-orders): run schedule status side-effects on board field mutations --- .../Helpers/WorkOrderBoardFieldMutations.cs | 16 +--------- .../WorkOrderBoardFieldMutationsTests.cs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs index c3a59a0..b9f785f 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -62,21 +62,7 @@ namespace SeaHaven.Services.Helpers } public static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) - { - var changes = new List(); - 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) { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs index aa83201..61eab70 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs @@ -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()); + } } From f3f9ac1b58cbe7ee2f26eb1dff155ac2612d319c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:23:13 -0300 Subject: [PATCH 12/17] feat(work-orders): derive lifecycle on board create and schedule PATCH --- .../WorkOrderBoardCreateService.cs | 2 ++ .../WorkOrderBoardUpdateService.cs | 20 ++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 03e4062..0305e1e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -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) { diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 631b2c0..b94762d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -637,6 +637,7 @@ namespace SeaHaven.Services.Implementation var changes = new List { change }; if (change.HasChanged) AppendIsAddOnRecalculation(workOrder, changes); + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); return changes; } @@ -785,21 +786,12 @@ namespace SeaHaven.Services.Implementation } private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) - { - var changes = new List(); - 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); From 12354f9bf7ef4eb4a9a1867f096118b94c95055b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:24:45 -0300 Subject: [PATCH 13/17] test(work-orders): cover SH-186 schedule status derivation --- .../WorkOrderBoardCreateServiceTests.cs | 39 +++++++- .../WorkOrderBoardUpdateServiceTests.cs | 99 ++++++++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index cdf7559..0da2c56 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -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(() => + var ex = await Assert.ThrowsAsync(() => service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, @@ -703,6 +738,8 @@ public class WorkOrderBoardCreateServiceTests LocationId = 1, LifecycleStatus = LifecycleStatus.Scheduled, }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index f37686e..967b115 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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,104 @@ 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); + } + + [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); + } + + [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_IncrementsRescheduleCount() { From d1e3fb03ce0fe590d9cefa51c8ac76049e541922 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 8 Sep 2026 21:31:55 -0300 Subject: [PATCH 14/17] fix(deploy): let EB health converge before failing the version gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elastic Beanstalk reports Ready as soon as a rollout finishes, before enhanced health has converged. Both verification gates decided on the first Ready poll, so a release whose version had activated correctly was failed on a health value that had not settled yet — and then rolled back. The failure message compounded it: run 34293894914 printed 'Environment became Ready without activating expected version a0fdd199...' when the active version was exactly a0fdd199... The discriminator was health, not version, which sends whoever reads the log after the wrong problem. Separate the two conditions, keep polling while the correct version is active but health has not settled, and report the last observed state on timeout. An environment that stays unhealthy for the full window still fails; this does not widen what counts as a good deploy. --- .github/workflows/deploy.yml | 46 ++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d327271..e189d9e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 @@ -724,17 +732,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 +838,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 From 8b4aec300f14ca9c1cd2734a54d3cda5a3ef3b15 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 9 Sep 2026 10:23:22 -0300 Subject: [PATCH 15/17] feat(work-orders): re-derive lifecycle when board status is patched Scheduled still requires a concrete date, and Incomplete/Pending with a date must promote even when only lifecycleStatus is sent. --- .../WorkOrderBoardCreateLifecycleRules.cs | 2 +- .../WorkOrderBoardUpdateService.cs | 33 +++- .../WorkOrderBoardCreateServiceTests.cs | 20 +++ .../WorkOrderBoardUpdateServiceTests.cs | 157 ++++++++++++++++++ 4 files changed, 202 insertions(+), 10 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs index 0f06f3a..f16fde2 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs @@ -33,7 +33,7 @@ namespace SeaHaven.Services.Helpers } if (requested == LifecycleStatus.Scheduled - && (!scheduledDate.HasValue || scheduleWeekOnly == true)) + && !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly)) { throw new WorkOrderBoardValidationException( "ScheduledRequiresDate", diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index b94762d..49a63cb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField), WorkOrderBoardFieldNames.Severity => new List { ApplySeverity(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, - WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField), WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField), WorkOrderBoardFieldNames.DueDate => new List { 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 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(); 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 ApplyAssignTo(WorkOrder workOrder, string? value, string auditField) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 0da2c56..dd2fd10 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -742,6 +742,26 @@ public class WorkOrderBoardCreateServiceTests Assert.Equal("ScheduledRequiresDate", ex.Code); } + [Fact] + public async Task Create_WithScheduledLifecycleWeekOnly_Throws() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + 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] public async Task Create_EmergencyWithSeverity_Persists() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 967b115..7f29f3b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -128,6 +128,9 @@ public class WorkOrderBoardUpdateServiceTests 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] @@ -154,6 +157,136 @@ public class WorkOrderBoardUpdateServiceTests 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(() => + 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(() => + 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] @@ -180,6 +313,30 @@ public class WorkOrderBoardUpdateServiceTests 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() { From e849991dcf31584276b4f34c205cdd296291346d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 9 Sep 2026 17:09:26 -0300 Subject: [PATCH 16/17] test(work-orders): seed scheduled concurrency scenario --- SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs index d1014b6..0aa2a17 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -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 } }); From 9a49a5c40a2c4aa92875e6e82681c1d3b580cc39 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 10 Sep 2026 10:55:45 -0300 Subject: [PATCH 17/17] fix(deploy): apply the health-convergence fix to the dev Terraform rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev Terraform rollback verify was the one gate the previous commit missed, and it is the copy that actually ran on 34293894914. It still decided on the first Ready poll: previous version correctly restored, health not yet converged, reported as a failed rollback. Split the version and health conditions the same way the other three gates now do — a Ready poll on the wrong version fails immediately and names the version that came up, while the correct version with unsettled health keeps polling inside the unchanged 80 x 15s budget. Timeout now reports the last observed status, version and health. --- .github/workflows/deploy.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e189d9e..1cdcf0b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -620,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: