mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
fix(work-orders): address procurement review findings
This commit is contained in:
parent
8a2e260177
commit
27bf81b7f8
14 changed files with 367 additions and 34 deletions
|
|
@ -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<IActionDescriptorCollectionProvider>()
|
||||
.ActionDescriptors
|
||||
.Items
|
||||
.OfType<ControllerActionDescriptor>()
|
||||
.Where(d => d.ControllerTypeInfo == typeof(WorkOrderReconciliationController))
|
||||
.ToList();
|
||||
|
||||
var routes = descriptors
|
||||
.SelectMany(d => (d.ActionConstraints ?? Array.Empty<IActionConstraintMetadata>())
|
||||
.OfType<HttpMethodActionConstraint>()
|
||||
.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<AuthorizeAttribute>(),
|
||||
attribute => attribute.Roles == "Admin"));
|
||||
}
|
||||
|
||||
private sealed class StubService : IWorkOrderReconciliationService
|
||||
{
|
||||
private readonly Guid _runId;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
"MaxResponseBytes": 4194304,
|
||||
"PollSeconds": 10,
|
||||
"ScheduleMinutes": 60,
|
||||
"LeaseSeconds": 120
|
||||
"LeaseSeconds": 180
|
||||
},
|
||||
"Sync": {
|
||||
"Enabled": true
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
46
SeaHaven.Services.Tests/WorkOrderExternalVersionTests.cs
Normal file
46
SeaHaven.Services.Tests/WorkOrderExternalVersionTests.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string?>
|
||||
{
|
||||
["WorkOrderReconciliation:Enabled"] = "true",
|
||||
["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30",
|
||||
["WorkOrderReconciliation:MaxRetries"] = "3",
|
||||
["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "100",
|
||||
["WorkOrderReconciliation:LeaseSeconds"] = "120"
|
||||
};
|
||||
|
||||
using var provider = BuildProvider(values);
|
||||
|
||||
Assert.Throws<OptionsValidationException>(() => provider
|
||||
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_reconciliation_does_not_require_a_request_budget_lease()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["WorkOrderReconciliation:Enabled"] = "false",
|
||||
["WorkOrderReconciliation:LeaseSeconds"] = "1"
|
||||
};
|
||||
|
||||
using var provider = BuildProvider(values);
|
||||
|
||||
Assert.False(provider
|
||||
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_reconciliation_rejects_a_lease_equal_to_the_request_budget()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["WorkOrderReconciliation:Enabled"] = "true",
|
||||
["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30",
|
||||
["WorkOrderReconciliation:MaxRetries"] = "0",
|
||||
["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "0",
|
||||
["WorkOrderReconciliation:LeaseSeconds"] = "30"
|
||||
};
|
||||
|
||||
using var provider = BuildProvider(values);
|
||||
|
||||
Assert.Throws<OptionsValidationException>(() => provider
|
||||
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildProvider(IDictionary<string, string?> values)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddBusinessServices(configuration);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<ProcurementWorkOrder>(
|
||||
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<ReconciliationJobSnapshot> 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(
|
||||
|
|
|
|||
|
|
@ -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<ApplicationDbContext>()
|
||||
.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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue