mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 08:21:05 -04:00
Server nests in Database mode get a read-only status bar label comparing their
open MaterialLedger holds with the sheets they cut, e.g. "Ledger: 2 of 4 held,
2 short". Nothing writes to the ledger and nothing waits on it.
- MaterialLedgerClient.GetHoldsAsync reads GET /api/stock/holds?openNestNestId=
with the closed LedgerHoldsResult set; unknown status strings are kept. The
request plumbing is shared with GetStockAsync.
- LedgerHoldSummary (OpenNest.Data) counts Proposed/Approved/Released/
AwaitingCloseout holds against the plate quantities, skipping the trailing
empty plate as /sheets does.
- NestHoldStatus refreshes on server open, after save, after storage or
Options changes and on label click; it never polls. The tooltip lists holds
in nest units. File-mode, unsaved and ledger-less nests show nothing.
- Setting Has Been Cut in Nest Info with holds still open shows a non-modal
note linking to {ledgerUrl}/Nests/{id}. Posting is not gated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
11 KiB
C#
253 lines
11 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 readonly Guid NestId = Guid.Parse("4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b");
|
|
|
|
[Fact]
|
|
public async Task HoldsAreListedWithTheKeyAndNestId()
|
|
{
|
|
HttpRequestMessage? seen = null;
|
|
var client = Client(request =>
|
|
{
|
|
seen = request;
|
|
return Json("""
|
|
{
|
|
"openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b",
|
|
"holds": [
|
|
{ "materialReservationId": 91, "itemCode": "DROP-1182", "widthIn": 30, "lengthIn": 52.5,
|
|
"status": "Approved", "locationCode": "B3", "createdAtUtc": "2026-10-20T14:00:00Z" },
|
|
{ "materialReservationId": 92, "itemCode": "FS-4896", "widthIn": 48, "lengthIn": 96,
|
|
"status": "ClosedConsumed", "locationCode": null, "createdAtUtc": "2026-10-20T14:00:00Z" }
|
|
]
|
|
}
|
|
""");
|
|
});
|
|
|
|
var ok = Assert.IsType<LedgerHoldsResult.Ok>(await client.GetHoldsAsync(NestId, default));
|
|
|
|
Assert.Equal("/api/stock/holds?openNestNestId=4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b", seen!.RequestUri!.PathAndQuery);
|
|
Assert.Equal("secret-key", Assert.Single(seen.Headers.GetValues(MaterialLedgerClient.ApiKeyHeader)));
|
|
Assert.Equal(2, ok.Holds.Count);
|
|
var first = ok.Holds[0];
|
|
Assert.Equal((91L, "DROP-1182", 30m, 52.5m, LedgerHoldStatuses.Approved, "B3"),
|
|
(first.MaterialReservationId, first.ItemCode, first.WidthIn, first.LengthIn, first.Status, first.LocationCode));
|
|
Assert.Null(ok.Holds[1].LocationCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoHoldsIsAnEmptyList()
|
|
{
|
|
var client = Client(_ => Json("""{ "openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b", "holds": [] }"""));
|
|
Assert.Empty(Assert.IsType<LedgerHoldsResult.Ok>(await client.GetHoldsAsync(NestId, default)).Holds);
|
|
|
|
var missing = Client(_ => Json("""{ "openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b" }"""));
|
|
Assert.Empty(Assert.IsType<LedgerHoldsResult.Ok>(await missing.GetHoldsAsync(NestId, default)).Holds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownHoldStatusIsKept()
|
|
{
|
|
var client = Client(_ => Json("""
|
|
{
|
|
"openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b",
|
|
"holds": [{ "materialReservationId": 5, "itemCode": "D-1", "widthIn": 10, "lengthIn": 20,
|
|
"status": "OnTruck", "locationCode": "Y", "createdAtUtc": "2026-10-20T14:00:00Z", "someNewField": 1 }]
|
|
}
|
|
"""));
|
|
var hold = Assert.Single(Assert.IsType<LedgerHoldsResult.Ok>(await client.GetHoldsAsync(NestId, default)).Holds);
|
|
Assert.Equal("OnTruck", hold.Status);
|
|
Assert.False(LedgerHoldStatuses.IsOpen(hold.Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HoldFailuresAreResults()
|
|
{
|
|
var sent = 0;
|
|
var notConfigured = Client(_ => { sent++; return Json("{}"); }, new MaterialLedgerSettings());
|
|
Assert.IsType<LedgerHoldsResult.NotConfigured>(await notConfigured.GetHoldsAsync(NestId, default));
|
|
Assert.Equal(0, sent);
|
|
|
|
var down = Client(_ => throw new HttpRequestException("No such host is known."));
|
|
Assert.Contains("No such host", Assert.IsType<LedgerHoldsResult.Unreachable>(
|
|
await down.GetHoldsAsync(NestId, default)).Reason);
|
|
|
|
var oldLedger = Client(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
|
Assert.Contains("no OpenNest holds route", Assert.IsType<LedgerHoldsResult.Unreachable>(
|
|
await oldLedger.GetHoldsAsync(NestId, default)).Reason);
|
|
|
|
var rejected = Client(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
|
Assert.IsType<LedgerHoldsResult.Unauthorized>(await rejected.GetHoldsAsync(NestId, default));
|
|
|
|
var garbage = Client(_ => Json("<html>login</html>"));
|
|
Assert.IsType<LedgerHoldsResult.Unreachable>(await garbage.GetHoldsAsync(NestId, default));
|
|
|
|
using var http = new HttpClient(new HangingHandler());
|
|
var slow = new MaterialLedgerClient(http, Configured, TimeSpan.FromMilliseconds(50));
|
|
Assert.Contains("No answer", Assert.IsType<LedgerHoldsResult.Unreachable>(
|
|
await slow.GetHoldsAsync(NestId, default)).Reason);
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|