using System.Net; using Amazon.Runtime; using Api.SeaHavenIndustries.Infrastructure; using Microsoft.Extensions.Options; using SeaHaven.Services.Configuration; using Xunit; namespace Api.SeaHavenIndustries.Tests; public sealed class ProcurementWorkOrderClientTests { [Fact] public async Task Signs_every_retry_with_session_credentials_and_reads_typed_page() { var handler = new RecordingHandler( new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), Json(HttpStatusCode.OK, """{"items":[{"work_order_id":"123","updated_at":"2026-07-24T12:00:00Z","wo_status":"new"}],"next_cursor":"opaque"}""")); var credentials = new RecordingCredentialsProvider(); var client = Create(handler, credentials); var page = await client.GetWorkOrdersAsync(null, 100, CancellationToken.None); Assert.Equal("123", Assert.Single(page.Items).WorkOrderId); Assert.Equal("opaque", page.NextCursor); Assert.Equal(2, handler.Requests.Count); Assert.Equal(2, credentials.CallCount); Assert.Equal( "AWS4-HMAC-SHA256 Credential=access/20260724/us-east-1/execute-api/aws4_request, " + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, " + "Signature=32f2ce331e25cf7e165126108eab8a0e7313fd051647df89ee8e658cfe4a967a", handler.Requests[0].Authorization); Assert.All(handler.Requests, request => { Assert.Equal("procurement-api.seahaven.com", request.Uri.Host); Assert.Contains("limit=100", request.Uri.Query); Assert.StartsWith("AWS4-HMAC-SHA256 Credential=access/", request.Authorization); Assert.Contains("/us-east-1/execute-api/aws4_request", request.Authorization); Assert.Equal("session-token", request.SecurityToken); }); } [Fact] public async Task Encodes_cursor_and_uses_separate_comment_path() { var handler = new RecordingHandler(Json(HttpStatusCode.OK, """{"items":[{"work_order_id":"123","comment_id":"c1","text":"mock"}],"next_cursor":null}""")); var client = Create(handler, new RecordingCredentialsProvider()); var page = await client.GetCommentsAsync("123", "a/b+c=", 50, CancellationToken.None); Assert.Equal("c1", Assert.Single(page.Items).CommentId); Assert.Equal( "/work-orders/123/comments?cursor=a%2Fb%2Bc%3D&limit=50", Assert.Single(handler.Requests).Uri.PathAndQuery); } [Fact] public async Task Retries_transient_network_failure_and_accepts_legacy_null_updated_at() { var handler = new ThrowingThenSuccessHandler(Json(HttpStatusCode.OK, """{"items":[{"work_order_id":"123","updated_at":null,"created_at":null}],"next_cursor":null}""")); var credentials = new RecordingCredentialsProvider(); var client = Create(handler, credentials); var page = await client.GetWorkOrdersAsync(null, 100, CancellationToken.None); Assert.Null(Assert.Single(page.Items).UpdatedAt); Assert.Equal(2, handler.CallCount); Assert.Equal(2, credentials.CallCount); } [Fact] public async Task Rejects_unapproved_origin_and_oversized_response() { var options = Options("https://attacker.invalid"); var client = new ProcurementWorkOrderClient( new HttpClient(new RecordingHandler(Json(HttpStatusCode.OK, """{"items":[]}"""))), new RecordingCredentialsProvider(), options, TimeProvider.System); await Assert.ThrowsAsync( () => client.GetWorkOrdersAsync(null, 100, CancellationToken.None)); var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[1025]) }); var bounded = Create(handler, new RecordingCredentialsProvider(), maxResponseBytes: 1024); await Assert.ThrowsAsync( () => bounded.GetWorkOrdersAsync(null, 100, CancellationToken.None)); } private static ProcurementWorkOrderClient Create( HttpMessageHandler handler, IProcurementAwsCredentialsProvider credentials, int maxResponseBytes = 4096) => new( new HttpClient(handler), credentials, Options("https://procurement-api.seahaven.com", maxResponseBytes), new FixedTimeProvider()); private static IOptionsMonitor Options( string baseUrl, int maxResponseBytes = 4096) => new TestOptions(new WorkOrderReconciliationOptions { Enabled = true, BaseUrl = baseUrl, MaxRetries = 1, RetryBaseDelayMilliseconds = 0, MaxResponseBytes = maxResponseBytes }); private static HttpResponseMessage Json(HttpStatusCode status, string json) => new(status) { Content = new StringContent(json) }; private sealed class RecordingCredentialsProvider : IProcurementAwsCredentialsProvider { public int CallCount { get; private set; } public Task GetAsync(CancellationToken cancellationToken) { CallCount++; return Task.FromResult(new ImmutableCredentials( "access", "secret", "session-token")); } } private sealed class RecordingHandler : HttpMessageHandler { private readonly Queue _responses; public RecordingHandler(params HttpResponseMessage[] responses) { _responses = new Queue(responses); } public List Requests { get; } = new(); protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Requests.Add(new RecordedRequest( request.RequestUri!, request.Headers.TryGetValues("Authorization", out var authorization) ? authorization.Single() : string.Empty, request.Headers.TryGetValues("X-Amz-Security-Token", out var values) ? values.Single() : null)); return Task.FromResult(_responses.Dequeue()); } } private sealed class ThrowingThenSuccessHandler : HttpMessageHandler { private readonly HttpResponseMessage _response; public ThrowingThenSuccessHandler(HttpResponseMessage response) { _response = response; } public int CallCount { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { CallCount++; return CallCount == 1 ? Task.FromException( new HttpRequestException("transient mock failure")) : Task.FromResult(_response); } } private sealed record RecordedRequest(Uri Uri, string Authorization, string? SecurityToken); private sealed class TestOptions : IOptionsMonitor { public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value; public WorkOrderReconciliationOptions CurrentValue { get; } public WorkOrderReconciliationOptions Get(string? name) => CurrentValue; public IDisposable? OnChange( Action listener) => null; } private sealed class FixedTimeProvider : TimeProvider { public override DateTimeOffset GetUtcNow() => DateTimeOffset.Parse("2026-07-24T12:00:00Z"); } }