mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
199 lines
7.8 KiB
C#
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");
|
|
}
|
|
}
|