diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs index 7ee4c23..7c313a7 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs @@ -1,6 +1,11 @@ using Api.SeaHavenIndustries.Controllers; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; @@ -37,6 +42,41 @@ public sealed class WorkOrderReconciliationControllerTests Assert.DoesNotContain("exception", result.Value!.ToString(), StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Framework_action_descriptors_expose_only_admin_get_and_post_reconciliation_routes() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore() + .AddApplicationPart(typeof(WorkOrderReconciliationController).Assembly); + using var provider = services.BuildServiceProvider(); + var descriptors = provider + .GetRequiredService() + .ActionDescriptors + .Items + .OfType() + .Where(d => d.ControllerTypeInfo == typeof(WorkOrderReconciliationController)) + .ToList(); + + var routes = descriptors + .SelectMany(d => (d.ActionConstraints ?? Array.Empty()) + .OfType() + .SelectMany(c => c.HttpMethods) + .Select(method => $"{method} {d.AttributeRouteInfo!.Template}")) + .ToHashSet(StringComparer.Ordinal); + + Assert.True(routes.SetEquals(new[] + { + "GET api/admin/work-order-reconciliation", + "POST api/admin/work-order-reconciliation" + })); + Assert.All(descriptors, descriptor => + Assert.Contains( + descriptor.ControllerTypeInfo.GetCustomAttributes(typeof(AuthorizeAttribute), true) + .OfType(), + attribute => attribute.Roles == "Admin")); + } + private sealed class StubService : IWorkOrderReconciliationService { private readonly Guid _runId; diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs index 0e6cafa..616f7aa 100644 --- a/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs @@ -27,7 +27,10 @@ namespace Api.SeaHavenIndustries.HostedServices { var nextScheduledAt = _timeProvider.GetUtcNow(); if (_options.CurrentValue.Enabled && _options.CurrentValue.RunOnStartup) + { await TriggerAsync("startup", stoppingToken); + nextScheduledAt = _timeProvider.GetUtcNow().AddMinutes(_options.CurrentValue.ScheduleMinutes); + } while (!stoppingToken.IsCancellationRequested) { diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index e44a852..eaf063c 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -61,7 +61,7 @@ "MaxResponseBytes": 4194304, "PollSeconds": 10, "ScheduleMinutes": 60, - "LeaseSeconds": 120 + "LeaseSeconds": 180 }, "Sync": { "Enabled": true diff --git a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs index abdf4fa..218769d 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs @@ -102,6 +102,8 @@ namespace SeaHaven.DataServices.Implementation workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description; workOrder.Description = mutation.Description; workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status; + if (mutation.LifecycleStatus.HasValue) + workOrder.LifecycleStatus = mutation.LifecycleStatus.Value; workOrder.Severity = mutation.Severity; workOrder.Priority = mutation.Priority; workOrder.ExternalAssignedTo = mutation.AssignedTo; diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs index c1580bb..75e9f7a 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs @@ -1,3 +1,5 @@ +using Data.SeaHavenIndustries.Enums; + namespace SeaHaven.DataServices.Interfaces { public interface IWorkOrderWebhookDataService @@ -24,6 +26,7 @@ namespace SeaHaven.DataServices.Interfaces public string? Title { get; init; } public string? Description { get; init; } public string? Status { get; init; } + public LifecycleStatus? LifecycleStatus { get; init; } public string? Severity { get; init; } public string? Priority { get; init; } public string? AssignedTo { get; init; } diff --git a/SeaHaven.Services.Tests/WorkOrderExternalVersionTests.cs b/SeaHaven.Services.Tests/WorkOrderExternalVersionTests.cs new file mode 100644 index 0000000..9f9699c --- /dev/null +++ b/SeaHaven.Services.Tests/WorkOrderExternalVersionTests.cs @@ -0,0 +1,46 @@ +using SeaHaven.Services.Helpers; +using Xunit; + +namespace SeaHaven.Services.Tests; + +public sealed class WorkOrderExternalVersionTests +{ + [Fact] + public void Work_order_inputs_from_webhook_and_reconciliation_have_the_same_canonical_hash() + { + var timestamp = DateTimeOffset.Parse("2026-07-24T12:00:00+02:00"); + var webhook = new WorkOrderExternalVersion.WorkOrder( + "42", "new", "Leaking pipe", "Acme", "S1", "A", "1 Main St", "2", "High", + "Alex", timestamp, timestamp.AddHours(1), timestamp.AddDays(1), "update", timestamp, + "emails/42.eml"); + var reconciliation = webhook with { DateReported = timestamp.ToUniversalTime() }; + + Assert.Equal( + WorkOrderExternalVersion.ComputeWorkOrder(webhook), + WorkOrderExternalVersion.ComputeWorkOrder(reconciliation)); + } + + [Fact] + public void Comment_inputs_from_webhook_and_reconciliation_have_the_same_20_slot_canonical_hash() + { + var createdAt = DateTimeOffset.Parse("2026-07-24T11:59:00+02:00"); + var ingestedAt = DateTimeOffset.Parse("2026-07-24T12:01:00+02:00"); + var webhook = new WorkOrderExternalVersion.Comment( + "42", "comment-7", "comment", "Alex", "A note", createdAt, ingestedAt, "emails/42.eml"); + var reconciliation = webhook with + { + CreatedAt = createdAt.ToUniversalTime(), + IngestedAt = ingestedAt.ToUniversalTime() + }; + + Assert.Equal( + WorkOrderExternalVersion.ComputeComment(webhook), + WorkOrderExternalVersion.ComputeComment(reconciliation)); + Assert.Equal( + WorkOrderExternalVersion.Compute( + "42", null, null, null, null, null, null, null, null, null, + null, null, null, "comment", "2026-07-24T09:59:00.0000000+00:00", "emails/42.eml", "comment-7", "Alex", + "A note", "2026-07-24T10:01:00.0000000+00:00"), + WorkOrderExternalVersion.ComputeComment(webhook)); + } +} diff --git a/SeaHaven.Services.Tests/WorkOrderReconciliationOptionsTests.cs b/SeaHaven.Services.Tests/WorkOrderReconciliationOptionsTests.cs new file mode 100644 index 0000000..bc0f4f3 --- /dev/null +++ b/SeaHaven.Services.Tests/WorkOrderReconciliationOptionsTests.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DependencyInjection; +using Xunit; + +namespace SeaHaven.Services.Tests; + +public sealed class WorkOrderReconciliationOptionsTests +{ + [Fact] + public void Enabled_reconciliation_rejects_a_lease_shorter_than_worst_case_request_budget() + { + var values = new Dictionary + { + ["WorkOrderReconciliation:Enabled"] = "true", + ["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30", + ["WorkOrderReconciliation:MaxRetries"] = "3", + ["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "100", + ["WorkOrderReconciliation:LeaseSeconds"] = "120" + }; + + using var provider = BuildProvider(values); + + Assert.Throws(() => provider + .GetRequiredService>().Value); + } + + [Fact] + public void Disabled_reconciliation_does_not_require_a_request_budget_lease() + { + var values = new Dictionary + { + ["WorkOrderReconciliation:Enabled"] = "false", + ["WorkOrderReconciliation:LeaseSeconds"] = "1" + }; + + using var provider = BuildProvider(values); + + Assert.False(provider + .GetRequiredService>().Value.Enabled); + } + + [Fact] + public void Enabled_reconciliation_rejects_a_lease_equal_to_the_request_budget() + { + var values = new Dictionary + { + ["WorkOrderReconciliation:Enabled"] = "true", + ["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30", + ["WorkOrderReconciliation:MaxRetries"] = "0", + ["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "0", + ["WorkOrderReconciliation:LeaseSeconds"] = "30" + }; + + using var provider = BuildProvider(values); + + Assert.Throws(() => provider + .GetRequiredService>().Value); + } + + private static ServiceProvider BuildProvider(IDictionary values) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + var services = new ServiceCollection(); + services.AddBusinessServices(configuration); + return services.BuildServiceProvider(); + } +} diff --git a/SeaHaven.Services/Configuration/WorkOrderReconciliationOptions.cs b/SeaHaven.Services/Configuration/WorkOrderReconciliationOptions.cs index 145207a..6e6ea48 100644 --- a/SeaHaven.Services/Configuration/WorkOrderReconciliationOptions.cs +++ b/SeaHaven.Services/Configuration/WorkOrderReconciliationOptions.cs @@ -17,6 +17,6 @@ namespace SeaHaven.Services.Configuration public int MaxResponseBytes { get; set; } = 4_194_304; public int PollSeconds { get; set; } = 10; public int ScheduleMinutes { get; set; } = 60; - public int LeaseSeconds { get; set; } = 120; + public int LeaseSeconds { get; set; } = 180; } } diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index 8080130..19467fe 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -49,7 +49,8 @@ namespace SeaHaven.Services.DependencyInjection && o.MaxResponseBytes is >= 1 and <= 16_777_216 && o.PollSeconds is >= 1 and <= 300 && o.ScheduleMinutes is >= 1 and <= 10_080 - && o.LeaseSeconds is >= 30 and <= 3_600), + && o.LeaseSeconds is >= 30 and <= 3_600 + && HasSufficientReconciliationLease(o)), "WorkOrderReconciliation configuration is invalid.") .ValidateOnStart(); @@ -80,5 +81,25 @@ namespace SeaHaven.Services.DependencyInjection return services; } + + private static bool HasSufficientReconciliationLease(WorkOrderReconciliationOptions options) + { + try + { + var attempts = checked((long)options.MaxRetries + 1); + var requestBudgetMilliseconds = checked( + attempts * options.RequestTimeoutSeconds * 1_000L); + var retryDelayMultiplier = checked((1L << options.MaxRetries) - 1L); + var retryDelayMilliseconds = checked( + retryDelayMultiplier * options.RetryBaseDelayMilliseconds); + var worstCaseMilliseconds = checked( + requestBudgetMilliseconds + retryDelayMilliseconds); + return checked((long)options.LeaseSeconds * 1_000L) > worstCaseMilliseconds; + } + catch (OverflowException) + { + return false; + } + } } } diff --git a/SeaHaven.Services/Helpers/WorkOrderExternalVersion.cs b/SeaHaven.Services/Helpers/WorkOrderExternalVersion.cs index 59a6c41..aa6de70 100644 --- a/SeaHaven.Services/Helpers/WorkOrderExternalVersion.cs +++ b/SeaHaven.Services/Helpers/WorkOrderExternalVersion.cs @@ -5,6 +5,50 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderExternalVersion { + public static string ComputeWorkOrder(WorkOrder value) => Compute( + value.WorkOrderId, + value.Status, + value.Description, + value.Customer, + value.SiteCode, + value.Building, + value.Address, + value.Severity, + value.Priority, + value.AssignedTo, + Format(value.DateReported), + Format(value.ScheduledStart), + Format(value.DueDate), + value.RecordType, + Format(value.CreatedAt), + value.SourceEmailS3Key, + null, + null, + null, + null); + + public static string ComputeComment(Comment value) => Compute( + value.WorkOrderId, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + value.RecordType, + Format(value.CreatedAt), + value.SourceEmailS3Key, + value.CommentId, + value.Commenter, + value.Text, + Format(value.IngestedAt)); + public static string Compute(params string?[] values) { var buffer = new StringBuilder(); @@ -18,5 +62,36 @@ namespace SeaHaven.Services.Helpers SHA256.HashData(Encoding.UTF8.GetBytes(buffer.ToString()))) .ToLowerInvariant(); } + + private static string? Format(DateTimeOffset? value) => + value?.ToUniversalTime().ToString("O"); + + public sealed record WorkOrder( + string WorkOrderId, + string? Status, + string? Description, + string? Customer, + string? SiteCode, + string? Building, + string? Address, + string? Severity, + string? Priority, + string? AssignedTo, + DateTimeOffset? DateReported, + DateTimeOffset? ScheduledStart, + DateTimeOffset? DueDate, + string? RecordType, + DateTimeOffset? CreatedAt, + string? SourceEmailS3Key); + + public sealed record Comment( + string WorkOrderId, + string CommentId, + string? RecordType, + string? Commenter, + string? Text, + DateTimeOffset? CreatedAt, + DateTimeOffset? IngestedAt, + string? SourceEmailS3Key); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs b/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs index 73e6609..845a684 100644 --- a/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; +using Data.SeaHavenIndustries.Enums; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; @@ -151,8 +152,10 @@ namespace SeaHaven.Services.Implementation for (var pageNumber = 0; pageNumber < options.MaxPages; pageNumber++) { var page = await _client.GetWorkOrdersAsync(cursor, options.PageSize, cancellationToken); + await EnsureLeaseAsync(lease, leaseDuration, cancellationToken); foreach (var item in page.Items) { + await EnsureLeaseAsync(lease, leaseDuration, cancellationToken); await _workOrders.ApplyAsync(Map(item), cancellationToken); workOrderCount++; commentCount += await ReconcileCommentsAsync( @@ -189,8 +192,10 @@ namespace SeaHaven.Services.Implementation cursor, options.PageSize, cancellationToken); + await EnsureLeaseAsync(lease, leaseDuration, cancellationToken); foreach (var comment in page.Items) { + await EnsureLeaseAsync(lease, leaseDuration, cancellationToken); await _workOrders.ApplyAsync(Map(comment), cancellationToken); count++; } @@ -223,7 +228,7 @@ namespace SeaHaven.Services.Implementation private WorkOrderWebhookMutation Map(ProcurementWorkOrder item) { var updatedAt = item.UpdatedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch; - var hash = WorkOrderExternalVersion.Compute( + var hash = WorkOrderExternalVersion.ComputeWorkOrder(new WorkOrderExternalVersion.WorkOrder( item.WorkOrderId, item.WoStatus, item.Description, @@ -234,16 +239,16 @@ namespace SeaHaven.Services.Implementation item.Severity, item.Priority, item.AssignedTo, - Format(item.DateReported), - Format(item.ScheduledStart), - Format(item.DueDate), + item.DateReported, + item.ScheduledStart, + item.DueDate, item.RecordType, - Format(item.CreatedAt), - item.SourceEmailS3Key, - null, - null, - null, - null); + item.CreatedAt, + item.SourceEmailS3Key)); + var status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus); + var lifecycleStatus = item.WoStatus == "cancelled" || item.RecordType == "cancellation" + ? LifecycleStatus.Canceled + : LifecycleStatusMapper.FromLegacyStatus(status); return new WorkOrderWebhookMutation { DeliveryId = ReceiptId("work-order", item.WorkOrderId, updatedAt, hash), @@ -259,7 +264,8 @@ namespace SeaHaven.Services.Implementation IsStateEvent = true, IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation", Description = item.Description, - Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus), + Status = status, + LifecycleStatus = lifecycleStatus, Severity = item.Severity, Priority = item.Priority ?? WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity), AssignedTo = item.AssignedTo, @@ -279,15 +285,15 @@ namespace SeaHaven.Services.Implementation private WorkOrderWebhookMutation Map(ProcurementWorkOrderComment item) { var updatedAt = item.IngestedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch; - var hash = WorkOrderExternalVersion.Compute( + var hash = WorkOrderExternalVersion.ComputeComment(new WorkOrderExternalVersion.Comment( item.WorkOrderId, item.CommentId, item.RecordType, item.Commenter, item.Text, - Format(item.CreatedAt), - Format(item.IngestedAt), - item.SourceEmailS3Key); + item.CreatedAt, + item.IngestedAt, + item.SourceEmailS3Key)); return new WorkOrderWebhookMutation { DeliveryId = ReceiptId("comment", item.CommentId, updatedAt, hash), @@ -332,8 +338,5 @@ namespace SeaHaven.Services.Implementation .ToLowerInvariant(); return $"reconcile:{digest}"; } - - private static string? Format(DateTimeOffset? value) => - value?.ToUniversalTime().ToString("O"); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs index 5bfd0c8..f5a3831 100644 --- a/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using Data.SeaHavenIndustries.Enums; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; @@ -267,7 +268,17 @@ namespace SeaHaven.Services.Implementation return false; var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant(); - var versionHash = WorkOrderExternalVersion.Compute( + var versionHash = isComment + ? WorkOrderExternalVersion.ComputeComment(new WorkOrderExternalVersion.Comment( + envelope.Data.WorkOrderId, + envelope.Data.CommentId!, + envelope.Data.RecordType, + envelope.Data.Commenter, + envelope.Data.Text, + envelope.Data.CreatedAt, + envelope.Data.IngestedAt, + envelope.Data.SourceEmailS3Key)) + : WorkOrderExternalVersion.ComputeWorkOrder(new WorkOrderExternalVersion.WorkOrder( envelope.Data.WorkOrderId, envelope.Data.WoStatus ?? envelope.Data.Status, envelope.Data.Description, @@ -278,17 +289,14 @@ namespace SeaHaven.Services.Implementation envelope.Data.Severity, envelope.Data.Priority, envelope.Data.AssignedTo, - envelope.Data.DateReported?.ToUniversalTime().ToString("O"), - envelope.Data.ScheduledStart?.ToUniversalTime().ToString("O"), - envelope.Data.DueDate?.ToUniversalTime().ToString("O"), + envelope.Data.DateReported, + envelope.Data.ScheduledStart, + envelope.Data.DueDate, envelope.Data.RecordType, - envelope.Data.CreatedAt?.ToUniversalTime().ToString("O"), - envelope.Data.SourceEmailS3Key, - envelope.Data.CommentId, - envelope.Data.Commenter, - envelope.Data.Text, - envelope.Data.IngestedAt?.ToUniversalTime().ToString("O")); + envelope.Data.CreatedAt, + envelope.Data.SourceEmailS3Key)); var statusSource = envelope.Data.WoStatus ?? envelope.Data.Status; + var status = WorkOrderIngestFieldMapper.MapStatus(statusSource); mutation = new WorkOrderWebhookMutation { DeliveryId = envelope.DeliveryId, @@ -305,7 +313,10 @@ namespace SeaHaven.Services.Implementation IsCancelled = envelope.EventType == "work_order.cancelled", Title = envelope.Data.Title, Description = envelope.Data.Description, - Status = WorkOrderIngestFieldMapper.MapStatus(statusSource), + Status = status, + LifecycleStatus = envelope.EventType == "work_order.cancelled" + ? LifecycleStatus.Canceled + : LifecycleStatusMapper.FromLegacyStatus(status), Severity = envelope.Data.Severity, Priority = envelope.Data.Priority ?? WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity), diff --git a/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs b/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs index 400bf36..af8da90 100644 --- a/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -51,6 +52,8 @@ public sealed class WorkOrderReconciliationTests Assert.Equal(4, mutations.Items.Count); Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1" && m.IsCancelled); + Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1" + && m.LifecycleStatus == LifecycleStatus.Canceled); Assert.Contains(mutations.Items, m => m.CommentId == "c2"); Assert.Equal(2, jobs.CompletedWorkOrders); Assert.Equal(2, jobs.CompletedComments); @@ -71,6 +74,27 @@ public sealed class WorkOrderReconciliationTests Assert.False(await service.RunPendingAsync(CancellationToken.None)); } + [Fact] + public async Task Lost_lease_after_fetch_prevents_work_order_persistence() + { + var mutations = new RecordingWorkOrders(); + var jobs = new RecordingJobs { LoseLeaseOnRenewal = true }; + var service = Create( + new MockClient( + new[] + { + new ProcurementPage( + new[] { WorkOrder("1", "new") }, + null) + }, + new()), + mutations, + jobs); + + Assert.False(await service.RunPendingAsync(CancellationToken.None)); + Assert.Empty(mutations.Items); + } + [Fact] public async Task Legacy_records_without_timestamps_use_stable_epoch_freshness() { @@ -299,6 +323,7 @@ public sealed class WorkOrderReconciliationTests public int CompletedWorkOrders { get; private set; } public int CompletedComments { get; private set; } public int RenewCount { get; private set; } + public bool LoseLeaseOnRenewal { get; init; } public Task EnqueueAsync( string reason, @@ -323,7 +348,7 @@ public sealed class WorkOrderReconciliationTests CancellationToken cancellationToken) { RenewCount++; - return Task.FromResult(true); + return Task.FromResult(!LoseLeaseOnRenewal); } public Task CompleteAsync( diff --git a/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs b/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs index 058a7ab..44513c2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; @@ -282,12 +283,43 @@ public sealed class WorkOrderWebhookDataServiceTests new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)) with { IsCancelled = true, - Status = "Open" + Status = "Open", + LifecycleStatus = LifecycleStatus.Canceled }; await data.ApplyAsync(mutation, CancellationToken.None); Assert.Equal("Cancelled", (await context.workOrders.SingleAsync()).Status); + Assert.Equal(LifecycleStatus.Canceled, (await context.workOrders.SingleAsync()).LifecycleStatus); + } + + [Fact] + public async Task Unmapped_imported_status_preserves_existing_lifecycle_status() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.workOrders.Add(new WorkOrder + { + ExternalWorkOrderId = "123", + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + var data = new WorkOrderWebhookDataService(context); + + await data.ApplyAsync( + Mutation( + "unmapped-status", + "hash", + "work_order.updated", + new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)), + CancellationToken.None); + + Assert.Equal(LifecycleStatus.Scheduled, (await context.workOrders.SingleAsync()).LifecycleStatus); } private static WorkOrderWebhookMutation Mutation(