diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs new file mode 100644 index 00000000..bd19bd9c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs @@ -0,0 +1,58 @@ +using System.Text; +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +/// +/// over a real : POSTs the recipe payload to +/// {baseUrl}/api/DelmiaRecipeDownload with the X-API-Key header. A reachable server +/// (any status) is ; a connection/timeout failure is . +/// +internal sealed class HttpRecipeSender(HttpClient http, string apiKey) : IRecipeSender +{ + private const string MethodPath = "/api/DelmiaRecipeDownload"; + + public async Task SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct) + { + var url = baseUrl.TrimEnd('/') + MethodPath; + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.TryAddWithoutValidation("X-API-Key", apiKey); + var json = JsonSerializer.Serialize(payload, NotifierJsonContext.Default.RecipeDownload); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var response = await http.SendAsync(request, ct); + var status = (int)response.StatusCode; + RecipeDownloadResult? body = null; + if (response.IsSuccessStatusCode) + { + var responseText = await response.Content.ReadAsStringAsync(ct); + body = TryParse(responseText); + } + + return new AttemptOutcome(AttemptKind.Connected, status, body, null); + } + catch (HttpRequestException ex) + { + return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, ex.Message); + } + catch (OperationCanceledException ex) when (!ct.IsCancellationRequested) + { + // No external cancellation requested → this is an HttpClient.Timeout, i.e. the node never answered. + return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "timeout: " + ex.Message); + } + } + + private static RecipeDownloadResult? TryParse(string responseText) + { + try + { + return JsonSerializer.Deserialize(responseText, NotifierJsonContext.Default.RecipeDownloadResult); + } + catch (JsonException) + { + return null; // unparseable body on a 2xx → treated as failure by the loop + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs new file mode 100644 index 00000000..dbda04df --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text; +using ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests; + +public class HttpRecipeSenderTests +{ + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(responder(request)); + } + } + + private static readonly RecipeDownload Payload = + new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" }; + + [Fact] + public async Task ConnectFailure_maps_to_ConnectFailed() + { + var handler = new StubHandler(_ => throw new HttpRequestException("refused")); + using var http = new HttpClient(handler); + var sender = new HttpRecipeSender(http, "sbk_x"); + var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None); + Assert.Equal(AttemptKind.ConnectFailed, outcome.Kind); + Assert.Equal(0, outcome.StatusCode); + } + + [Fact] + public async Task Success_200_parses_body_and_sends_header_and_path() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"Result\":true,\"ResultText\":\"\"}", Encoding.UTF8, "application/json"), + }); + using var http = new HttpClient(handler); + var sender = new HttpRecipeSender(http, "sbk_x"); + var outcome = await sender.SendAsync("http://a/", Payload, CancellationToken.None); + Assert.Equal(AttemptKind.Connected, outcome.Kind); + Assert.Equal(200, outcome.StatusCode); + Assert.NotNull(outcome.Body); + Assert.True(outcome.Body!.Result); + Assert.Equal("sbk_x", handler.LastRequest!.Headers.GetValues("X-API-Key").Single()); + Assert.Equal("http://a/api/DelmiaRecipeDownload", handler.LastRequest.RequestUri!.ToString()); + } + + [Fact] + public async Task Non2xx_is_Connected_with_status() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + using var http = new HttpClient(handler); + var sender = new HttpRecipeSender(http, "sbk_x"); + var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None); + Assert.Equal(AttemptKind.Connected, outcome.Kind); + Assert.Equal(500, outcome.StatusCode); + } +}