From f0dd016207a88fd95907911cea3081149fcaf185 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 04:06:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(esg):=20TimeoutSeconds=20in=20ExternalSyst?= =?UTF-8?q?em=20UI=20form=20+=20Component-ExternalSystemGateway=20doc=20sy?= =?UTF-8?q?nc=20=E2=80=94=20closes=20the=20spec-drift=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../Component-ExternalSystemGateway.md | 4 +- .../Pages/Design/ExternalSystemForm.razor | 11 +++- .../ExternalSystemFormAuditDrillinTests.cs | 62 +++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/docs/requirements/Component-ExternalSystemGateway.md b/docs/requirements/Component-ExternalSystemGateway.md index dd3c4dd6..1540528a 100644 --- a/docs/requirements/Component-ExternalSystemGateway.md +++ b/docs/requirements/Component-ExternalSystemGateway.md @@ -35,7 +35,7 @@ Each external system definition includes: - **Authentication**: One of: - **API Key**: Header name (e.g., `X-API-Key`) and key value. - **Basic Auth**: Username and password. -- **Timeout**: Per-system timeout for all method calls (e.g., 30 seconds). Applies to the HTTP request round-trip. +- **Timeout**: Per-system timeout for all method calls (e.g., 30 seconds). Applies to the HTTP request round-trip. `0` (the default) means "unset" — the gateway's configured `DefaultHttpTimeout` applies instead. Set via the Central UI External System form's "Timeout (seconds)" field; ships to sites in the deployment artifact like the other definition fields. - **Retry Settings**: Max retry count, fixed time between retries (used by Store-and-Forward Engine for transient failures only). - **Method Definitions**: List of available API methods, each with: - Method name. @@ -98,7 +98,7 @@ Scripts choose between two call modes per invocation, mirroring the dual-mode da ## Call Timeout & Error Handling -- Each external system definition specifies a **timeout** that applies to all method calls on that system. +- Each external system definition specifies a **timeout** (`TimeoutSeconds`) that applies to all method calls on that system. `0` means unset — the gateway's configured `DefaultHttpTimeout` applies instead. Editable via the Central UI External System form ("Timeout (seconds)", helper text "0 = use gateway default") and deployed to sites in the artifact alongside the rest of the definition. - Error classification by HTTP response: - **Transient failures** (connection refused, timeout, HTTP 408, 429, 5xx): Behavior depends on call mode — `CachedCall` buffers for retry; `Call` returns error to script. - **Permanent failures** (HTTP 4xx except 408/429): Always returned to the calling script regardless of call mode. Logged to Site Event Logging. For `CachedCall`, the failure is additionally recorded as a terminal `Failed` tracking record — so even a never-buffered cached call has an authoritative status record. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor index 35acb1f5..5834a568 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor @@ -62,6 +62,11 @@ +
+ + +
0 = use gateway default
+
@if (_formError != null) { @@ -85,6 +90,7 @@ private string? _authConfig; private int _maxRetries = 3; private int _retryDelaySeconds = 5; + private int _timeoutSeconds; private string? _formError; private ExternalSystemDefinition? _existing; @@ -112,6 +118,7 @@ _authConfig = _existing.AuthConfiguration; _maxRetries = _existing.MaxRetries; _retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds; + _timeoutSeconds = _existing.TimeoutSeconds; } } catch (Exception ex) { _formError = ex.Message; } @@ -138,6 +145,7 @@ _existing.AuthConfiguration = _authConfig?.Trim(); _existing.MaxRetries = _maxRetries; _existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds); + _existing.TimeoutSeconds = _timeoutSeconds; await ExternalSystemRepository.UpdateExternalSystemAsync(_existing); } else @@ -146,7 +154,8 @@ { AuthConfiguration = _authConfig?.Trim(), MaxRetries = _maxRetries, - RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds) + RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds), + TimeoutSeconds = _timeoutSeconds }; await ExternalSystemRepository.AddExternalSystemAsync(es); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs index b2e78746..3070dce0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components; @@ -67,4 +68,65 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]")); }); } + + [Fact] + public void Form_RendersTimeoutSecondsInput_WithGatewayDefaultHelperText() + { + // PLAN-06 Task 12: the per-system TimeoutSeconds column (Task 8) needs a + // Central UI form field so Design users can actually set it. + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("Timeout (seconds)", cut.Markup); + Assert.Contains("0 = use gateway default", cut.Markup); + }); + } + + [Fact] + public void SavingNewSystem_WithTimeoutSeconds_PassesTimeoutSecondsToRepository() + { + var cut = Render(); + + // Re-query between each Change(): two-way binding re-renders the form and + // invalidates previously found element references. + cut.FindAll("input[type=text]")[0].Change("ERP-Beta"); // Name + cut.FindAll("input[type=text]")[1].Change("https://erp-beta.example.test"); // Endpoint URL + cut.FindAll("input[type=number]")[2].Change("20"); // Timeout (seconds) + + cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click(); + + cut.WaitForAssertion(() => + { + _repo.Received().AddExternalSystemAsync( + Arg.Is(e => e.TimeoutSeconds == 20)); + _repo.Received().SaveChangesAsync(); + }); + } + + [Fact] + public void SavingEditedSystem_UpdatesTimeoutSecondsOnExisting() + { + var existing = new ExternalSystemDefinition("ERP-Delta", "https://erp-delta.example.test", "ApiKey") + { + Id = 11, + TimeoutSeconds = 10, + }; + _repo.GetExternalSystemByIdAsync(11, Arg.Any()).Returns(existing); + + var cut = Render(p => p.Add(c => c.Id, 11)); + cut.WaitForState(() => cut.FindAll("input[type=number]").Count >= 3); + + // The Timeout field must be pre-filled from the existing entity on load. + Assert.Contains(cut.FindAll("input[type=number]"), i => i.GetAttribute("value") == "10"); + + cut.FindAll("input[type=number]")[2].Change("60"); // Timeout (seconds) + cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click(); + + cut.WaitForAssertion(() => + { + Assert.Equal(60, existing.TimeoutSeconds); + _repo.Received().UpdateExternalSystemAsync(existing); + }); + } }