shoc-backend/Api.SeaHavenIndustries.Tests/ProcurementWorkOrderClientTests.cs
2026-07-24 22:13:25 -03:00

199 lines
7.8 KiB
C#

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<InvalidOperationException>(
() => 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<InvalidDataException>(
() => 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<WorkOrderReconciliationOptions> 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<ImmutableCredentials> GetAsync(CancellationToken cancellationToken)
{
CallCount++;
return Task.FromResult(new ImmutableCredentials(
"access",
"secret",
"session-token"));
}
}
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
public RecordingHandler(params HttpResponseMessage[] responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
public List<RecordedRequest> Requests { get; } = new();
protected override Task<HttpResponseMessage> 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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
CallCount++;
return CallCount == 1
? Task.FromException<HttpResponseMessage>(
new HttpRequestException("transient mock failure"))
: Task.FromResult(_response);
}
}
private sealed record RecordedRequest(Uri Uri, string Authorization, string? SecurityToken);
private sealed class TestOptions : IOptionsMonitor<WorkOrderReconciliationOptions>
{
public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value;
public WorkOrderReconciliationOptions CurrentValue { get; }
public WorkOrderReconciliationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(
Action<WorkOrderReconciliationOptions, string?> listener) => null;
}
private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() =>
DateTimeOffset.Parse("2026-07-24T12:00:00Z");
}
}