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:
Joseph Doherty
2026-08-19 14:57:21 -04:00
parent 013257ec7c
commit 50a77a8170
9 changed files with 383 additions and 2 deletions
@@ -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();