feat(esg): TimeoutSeconds in ExternalSystem UI form + Component-ExternalSystemGateway doc sync — closes the spec-drift gap
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -35,7 +35,7 @@ Each external system definition includes:
|
|||||||
- **Authentication**: One of:
|
- **Authentication**: One of:
|
||||||
- **API Key**: Header name (e.g., `X-API-Key`) and key value.
|
- **API Key**: Header name (e.g., `X-API-Key`) and key value.
|
||||||
- **Basic Auth**: Username and password.
|
- **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).
|
- **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 Definitions**: List of available API methods, each with:
|
||||||
- Method name.
|
- Method name.
|
||||||
@@ -98,7 +98,7 @@ Scripts choose between two call modes per invocation, mirroring the dual-mode da
|
|||||||
|
|
||||||
## Call Timeout & Error Handling
|
## 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:
|
- 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+10
-1
@@ -62,6 +62,11 @@
|
|||||||
<label class="form-label">Retry Delay (seconds)</label>
|
<label class="form-label">Retry Delay (seconds)</label>
|
||||||
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
|
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Timeout (seconds)</label>
|
||||||
|
<input type="number" class="form-control" @bind="_timeoutSeconds" min="0" />
|
||||||
|
<div class="form-text">0 = use gateway default</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_formError != null)
|
@if (_formError != null)
|
||||||
{
|
{
|
||||||
@@ -85,6 +90,7 @@
|
|||||||
private string? _authConfig;
|
private string? _authConfig;
|
||||||
private int _maxRetries = 3;
|
private int _maxRetries = 3;
|
||||||
private int _retryDelaySeconds = 5;
|
private int _retryDelaySeconds = 5;
|
||||||
|
private int _timeoutSeconds;
|
||||||
private string? _formError;
|
private string? _formError;
|
||||||
|
|
||||||
private ExternalSystemDefinition? _existing;
|
private ExternalSystemDefinition? _existing;
|
||||||
@@ -112,6 +118,7 @@
|
|||||||
_authConfig = _existing.AuthConfiguration;
|
_authConfig = _existing.AuthConfiguration;
|
||||||
_maxRetries = _existing.MaxRetries;
|
_maxRetries = _existing.MaxRetries;
|
||||||
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
|
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
|
||||||
|
_timeoutSeconds = _existing.TimeoutSeconds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _formError = ex.Message; }
|
catch (Exception ex) { _formError = ex.Message; }
|
||||||
@@ -138,6 +145,7 @@
|
|||||||
_existing.AuthConfiguration = _authConfig?.Trim();
|
_existing.AuthConfiguration = _authConfig?.Trim();
|
||||||
_existing.MaxRetries = _maxRetries;
|
_existing.MaxRetries = _maxRetries;
|
||||||
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
|
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
|
||||||
|
_existing.TimeoutSeconds = _timeoutSeconds;
|
||||||
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
|
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -146,7 +154,8 @@
|
|||||||
{
|
{
|
||||||
AuthConfiguration = _authConfig?.Trim(),
|
AuthConfiguration = _authConfig?.Trim(),
|
||||||
MaxRetries = _maxRetries,
|
MaxRetries = _maxRetries,
|
||||||
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
|
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds),
|
||||||
|
TimeoutSeconds = _timeoutSeconds
|
||||||
};
|
};
|
||||||
await ExternalSystemRepository.AddExternalSystemAsync(es);
|
await ExternalSystemRepository.AddExternalSystemAsync(es);
|
||||||
}
|
}
|
||||||
|
|||||||
+62
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
@@ -67,4 +68,65 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
|
|||||||
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
|
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<ExternalSystemForm>();
|
||||||
|
|
||||||
|
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<ExternalSystemForm>();
|
||||||
|
|
||||||
|
// 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<ExternalSystemDefinition>(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<CancellationToken>()).Returns(existing);
|
||||||
|
|
||||||
|
var cut = Render<ExternalSystemForm>(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user