Files
OpenNest/OpenNest.Tests/Data/MaterialLedgerClientTests.cs
T
ajandClaude Opus 5.5 ea1decc097 feat(autonest): load sheet and drop stock from MaterialLedger
Auto Nest's Stock Options gets a Load from ledger button that reads the nest
material's free sheets and drops from MaterialLedger's API-key-protected
GET /api/stock/opennest. Ledger rows are request-local: the engine sees them as
ordinary NestPlateStock rows with ledger:{n} ids and finite quantities, and they
are never written to Nest.PlateOptions.

- OpenNest.Data/MaterialLedger: settings file, read-only client (X-Api-Key,
  5 s timeout, closed LedgerStockResult set) and LedgerStockMapper (bundles and
  sheets one row each, same-size drops grouped with ordered item codes, unknown
  shapes and too-small items skipped, inch/mm conversion).
- Options: MaterialLedger URL and API key (DPAPI, CurrentUser) with Test.
- Auto Nest grid: Use, Qty and Source columns for every row; PlateOption gains
  request-local Quantity and Id, which NestStockBuilder passes to the engine.
  Ledger rows score by area; hand-typed costs stay saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00

167 lines
7.2 KiB
C#

using System.Net;
using System.Net.Http;
using System.Text;
using OpenNest.Data.MaterialLedger;
namespace OpenNest.Tests.Data;
public class MaterialLedgerClientTests
{
private const string MappedBody = """
{
"mapped": true,
"specs": [{ "materialSpecId": 7, "displayName": "A36 1/4", "thicknessIn": 0.25, "gaugeLabel": null }],
"items": [{
"itemCode": "D-101", "materialSpecId": 7, "kind": "Drop", "widthIn": 24.5, "lengthIn": 36,
"quantity": 1, "shape": "Rectangular", "locationCode": "R1-A", "locationName": "Rack 1 A",
"parentLocationCode": "R1", "costPerUnit": null, "lastObservedAtUtc": "2026-10-20T14:00:00Z"
}]
}
""";
private static readonly MaterialLedgerSettings Configured = new() { BaseUrl = "http://ledger:5000/", ApiKey = "secret-key" };
[Fact]
public async Task NotConfiguredNeverSendsARequest()
{
var sent = 0;
var client = Client(_ => { sent++; return Json(MappedBody); }, new MaterialLedgerSettings());
Assert.IsType<LedgerStockResult.NotConfigured>(await client.GetStockAsync("Steel", "A36", 0.25, default));
Assert.Equal(0, sent);
}
[Fact]
public async Task MappedStockIsOkAndSendsTheKeyAndQuery()
{
HttpRequestMessage? seen = null;
var client = Client(request => { seen = request; return Json(MappedBody); });
var ok = Assert.IsType<LedgerStockResult.Ok>(await client.GetStockAsync("Stainless Steel", "304 #4", 0.18750, default));
Assert.Equal("secret-key", Assert.Single(seen!.Headers.GetValues(MaterialLedgerClient.ApiKeyHeader)));
Assert.Equal("/api/stock/opennest?material=Stainless%20Steel&grade=304%20%234&thicknessIn=0.1875",
seen.RequestUri!.PathAndQuery);
Assert.Equal("ledger", seen.RequestUri.Host);
var spec = Assert.Single(ok.Specs);
Assert.Equal(0.25m, spec.ThicknessIn);
var item = Assert.Single(ok.Items);
Assert.Equal("D-101", item.ItemCode);
Assert.Equal(LedgerItemKinds.Drop, item.Kind);
Assert.Equal(24.5m, item.WidthIn);
Assert.Null(item.CostPerUnit);
}
[Fact]
public async Task UnmappedMaterialIsDistinctFromEmptyStock()
{
var unmapped = Client(_ => Json("""{ "mapped": false, "specs": [], "items": [] }"""));
Assert.IsType<LedgerStockResult.Unmapped>(await unmapped.GetStockAsync("Unobtainium", "", 1, default));
var empty = Client(_ => Json("""{ "mapped": true, "specs": [], "items": [] }"""));
var ok = Assert.IsType<LedgerStockResult.Ok>(await empty.GetStockAsync("Steel", "A36", 0.25, default));
Assert.Empty(ok.Items);
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task RejectedKeyIsUnauthorized(HttpStatusCode status)
{
var client = Client(_ => new HttpResponseMessage(status));
Assert.IsType<LedgerStockResult.Unauthorized>(await client.GetStockAsync("Steel", "A36", 0.25, default));
}
[Theory]
[InlineData(HttpStatusCode.NotFound, "no OpenNest stock route")]
[InlineData(HttpStatusCode.InternalServerError, "HTTP 500")]
public async Task OtherStatusesAreUnreachable(HttpStatusCode status, string reason)
{
var client = Client(_ => new HttpResponseMessage(status));
var result = Assert.IsType<LedgerStockResult.Unreachable>(await client.GetStockAsync("Steel", "A36", 0.25, default));
Assert.Contains(reason, result.Reason);
}
[Fact]
public async Task NetworkFailureAndGarbageAreUnreachable()
{
var down = Client(_ => throw new HttpRequestException("No such host is known."));
Assert.Contains("No such host", Assert.IsType<LedgerStockResult.Unreachable>(
await down.GetStockAsync("Steel", "A36", 0.25, default)).Reason);
var garbage = Client(_ => Json("<html>login</html>"));
Assert.IsType<LedgerStockResult.Unreachable>(await garbage.GetStockAsync("Steel", "A36", 0.25, default));
}
[Fact]
public async Task InvalidUrlIsUnreachableWithoutARequest()
{
var client = Client(_ => throw new InvalidOperationException("sent"),
new MaterialLedgerSettings { BaseUrl = "ftp://ledger" });
Assert.IsType<LedgerStockResult.Unreachable>(await client.GetStockAsync("Steel", "A36", 0.25, default));
}
[Fact]
public async Task SlowLedgerTimesOutAsUnreachable()
{
using var http = new HttpClient(new HangingHandler());
var client = new MaterialLedgerClient(http, Configured, TimeSpan.FromMilliseconds(50));
var result = Assert.IsType<LedgerStockResult.Unreachable>(await client.GetStockAsync("Steel", "A36", 0.25, default));
Assert.Contains("No answer", result.Reason);
Assert.Equal(TimeSpan.FromSeconds(5), MaterialLedgerClient.RequestTimeout);
}
[Fact]
public async Task CallerCancellationThrows()
{
using var http = new HttpClient(new HangingHandler());
var client = new MaterialLedgerClient(http, Configured);
using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => client.GetStockAsync("Steel", "A36", 0.25, cancel.Token));
}
[Fact]
public async Task UnknownKindAndShapeStillDeserialize()
{
var client = Client(_ => Json("""
{
"mapped": true, "specs": [],
"items": [{
"itemCode": "X-1", "materialSpecId": 7, "kind": "Coil", "widthIn": 48, "lengthIn": 96,
"quantity": 2, "shape": "Circular", "locationCode": "", "locationName": "",
"parentLocationCode": null, "costPerUnit": 12.5, "lastObservedAtUtc": "2026-10-20T14:00:00Z",
"someNewField": 1
}]
}
"""));
var ok = Assert.IsType<LedgerStockResult.Ok>(await client.GetStockAsync("Steel", "A36", 0.25, default));
var item = Assert.Single(ok.Items);
Assert.Equal("Coil", item.Kind);
Assert.Equal("Circular", item.Shape);
}
private static MaterialLedgerClient Client(Func<HttpRequestMessage, HttpResponseMessage> reply,
MaterialLedgerSettings? settings = null) =>
new(new HttpClient(new Handler(reply)), settings ?? Configured);
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class Handler(Func<HttpRequestMessage, HttpResponseMessage> reply) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(reply(request));
}
}
private sealed class HangingHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
throw new InvalidOperationException("unreachable");
}
}
}