fix(transport): carry ExternalSystem TimeoutSeconds through bundles + diff (arch-review 2026-08-19 P7-1, row-27 shape)
TimeoutSeconds is the per-system HTTP timeout ExternalSystemClient reads at call time and is deployed to sites (proto field 6), but Transport bundles silently dropped it: the DTO never carried it, export never wrote it, the importer's Add path reset it to 0, the Overwrite path left the target's stale value untouched, and ArtifactDiff never compared it (so a bundle whose only change was a tuned timeout previewed as Identical). Adds TimeoutSeconds as an additive-optional trailing field on ExternalSystemDto (schemaVersion stays 1.1, bundleFormatVersion unchanged) and threads it through EntitySerializer (export + import), BundleImporter (Add and Overwrite), and ArtifactDiff.CompareExternalSystem. Old-bundle semantics pinned: an absent field (null) falls back to the entity default on Add and leaves the target's existing value untouched on Overwrite/diff. Beefed up the RoundTripEquivalenceTests external-system fixture with a non-default TimeoutSeconds so the reflection round-trip guard actually exercises the field instead of comparing default against default. Swept the rest of the ExternalSystemDto shape against ExternalSystemDefinition field-by-field; no other operative field is missing.
This commit is contained in:
+188
@@ -789,6 +789,194 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.Equal(1, result.Overwritten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_adds_new_external_system_with_TimeoutSeconds()
|
||||
{
|
||||
// arch-review 2026-08-19 P7-1 (row-27 shape): TimeoutSeconds is the
|
||||
// per-system HTTP timeout ExternalSystemClient reads at call time — it must
|
||||
// survive Add, not just the retry policy (T-002 above covers the sibling
|
||||
// Methods-drop regression on Overwrite; this is the Add-path fidelity gap).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey")
|
||||
{
|
||||
TimeoutSeconds = 120,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
// Wipe so the import exercises the Add path.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
||||
Assert.Equal(120, sys.TimeoutSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Overwrite_restores_TimeoutSeconds_to_bundle_value()
|
||||
{
|
||||
// Seed with TimeoutSeconds=120, export, drift the target to 45, then Overwrite
|
||||
// and assert the bundle's value wins (mirrors the retry-policy Overwrite
|
||||
// coverage — TimeoutSeconds was the one scalar field the Overwrite branch never
|
||||
// touched at all, so it silently kept whatever the target already had).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey")
|
||||
{
|
||||
TimeoutSeconds = 120,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
||||
sys.TimeoutSeconds = 45; // now the only field that differs from the bundle
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
||||
Assert.Equal(120, sys.TimeoutSeconds);
|
||||
}
|
||||
Assert.Equal(1, result.Overwritten);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// arch-review 2026-08-19 P7-1: old-bundle semantics for TimeoutSeconds. Hand-packs
|
||||
// a BundleContentDto with an ExternalSystemDto whose TimeoutSeconds is left at its
|
||||
// default (null) — the same in-memory shape a pre-P7-1 bundle deserializes to,
|
||||
// since the field is simply absent from that JSON text.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
private async Task<Guid> PackAndLoadExternalSystemBundleAsync(params ExternalSystemDto[] externalSystems)
|
||||
{
|
||||
var content = new BundleContentDto(
|
||||
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||
Templates: Array.Empty<TemplateDto>(),
|
||||
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||
ExternalSystems: externalSystems,
|
||||
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
||||
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
|
||||
var contentBytes = serializer.SerializeContentBytes(content);
|
||||
var manifest = manifestBuilder.Build(
|
||||
sourceEnvironment: "dev",
|
||||
exportedBy: "alice",
|
||||
scadaBridgeVersion: "1.0.0",
|
||||
encryption: null,
|
||||
summary: new BundleSummary(0, 0, 0, externalSystems.Length, 0, 0, 0, 0),
|
||||
contents: Array.Empty<ManifestContentEntry>(),
|
||||
contentBytes: contentBytes);
|
||||
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await packed.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
return session.SessionId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Add_with_old_bundle_missing_TimeoutSeconds_uses_entity_default()
|
||||
{
|
||||
var dto = new ExternalSystemDto("Erp", "https://erp.example", "ApiKey",
|
||||
MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Methods: [], Secrets: null,
|
||||
TimeoutSeconds: null); // old-bundle shape — the field never existed on the wire.
|
||||
var sessionId = await PackAndLoadExternalSystemBundleAsync(dto);
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
||||
Assert.Equal(0, sys.TimeoutSeconds); // entity default, not an exception or -1.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Overwrite_with_old_bundle_missing_TimeoutSeconds_preserves_target_value()
|
||||
{
|
||||
// The target already has a tuned timeout; an old bundle that predates this field
|
||||
// must not reset it to 0 on Overwrite — there is nothing on the wire to apply, so
|
||||
// the existing value stands.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey")
|
||||
{
|
||||
TimeoutSeconds = 90,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var dto = new ExternalSystemDto("Erp", "https://erp.example", "ApiKey",
|
||||
MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Methods: [], Secrets: null,
|
||||
TimeoutSeconds: null); // old-bundle shape.
|
||||
var sessionId = await PackAndLoadExternalSystemBundleAsync(dto);
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
||||
Assert.Equal(90, sys.TimeoutSeconds); // untouched — bundle carried no opinion.
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// S10b: SMS provider config bundle apply. Mirrors the SMTP apply path —
|
||||
// create a new config (Add) and overwrite an existing one (matched by the
|
||||
|
||||
@@ -447,6 +447,11 @@ public sealed class RoundTripEquivalenceTests : IDisposable
|
||||
AuthConfiguration = "{\"apiKey\":\"super-secret\"}", // rides SecretsBlock
|
||||
MaxRetries = 6,
|
||||
RetryDelay = TimeSpan.FromSeconds(17),
|
||||
// arch-review 2026-08-19 P7-1: a default-0 fixture value made this
|
||||
// reflection guard vacuous for TimeoutSeconds (comparing default against
|
||||
// default survives even a serializer that drops the field entirely) — a
|
||||
// non-default value makes the guard actually exercise the field.
|
||||
TimeoutSeconds = 120,
|
||||
};
|
||||
ctx.ExternalSystemDefinitions.Add(sys);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
Reference in New Issue
Block a user