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
@@ -201,6 +201,52 @@ public sealed class BundleDtoSerializationTests
Assert.Empty(content.Instances);
}
// --- arch-review 2026-08-19 P7-1: ExternalSystem TimeoutSeconds wire format ---
/// <summary>
/// Pins the old-bundle semantics: a bundle written before <c>TimeoutSeconds</c>
/// existed carries no such property in the JSON text at all (not even a null). It
/// must deserialize to a null DTO value — never to 0, which is a distinct, meaningful
/// wire value (the entity's own "unset, gateway default applies" sentinel) — so
/// BundleImporter can tell "not specified by this bundle" apart from "explicitly
/// unset" and apply the documented fallback (Add: entity default; Overwrite: leave
/// the target's existing value untouched).
/// </summary>
[Fact]
public void LegacyExternalSystem_MissingTimeoutSeconds_DeserializesToNull()
{
const string legacyJson = """
{
"TemplateFolders": [],
"Templates": [],
"SharedScripts": [],
"ExternalSystems": [
{
"Name": "erp",
"BaseUrl": "https://erp/api",
"AuthType": "ApiKey",
"MaxRetries": 3,
"RetryDelay": "00:00:05",
"Methods": [],
"Secrets": null
}
],
"DatabaseConnections": [],
"NotificationLists": [],
"SmtpConfigs": [],
"ApiMethods": []
}
""";
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
Assert.NotNull(content);
var sys = Assert.Single(content!.ExternalSystems);
Assert.Equal("erp", sys.Name);
Assert.Equal(3, sys.MaxRetries);
Assert.Null(sys.TimeoutSeconds);
}
// --- S10: SMS recipient PhoneNumber + SmsConfig wire format ---------------
[Fact]