50a77a8170
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.
714 lines
29 KiB
C#
714 lines
29 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="ArtifactDiff"/>: the structured per-line Myers code diff
|
|
/// embedded in <c>FieldDiffJson</c> (T20) and the M8 Site / DataConnection /
|
|
/// Instance compares. The diff records are private, so assertions parse the
|
|
/// serialized <see cref="ImportPreviewItem.FieldDiffJson"/>.
|
|
/// </summary>
|
|
public sealed class ArtifactDiffTests
|
|
{
|
|
private readonly ArtifactDiff _diff = new();
|
|
|
|
// ---- JSON inspection helpers ----
|
|
|
|
private static JsonElement Changes(ImportPreviewItem item)
|
|
{
|
|
Assert.NotNull(item.FieldDiffJson);
|
|
using var doc = JsonDocument.Parse(item.FieldDiffJson!);
|
|
// Clone so the element survives the using-scope disposal.
|
|
return doc.RootElement.GetProperty("changes").Clone();
|
|
}
|
|
|
|
private static JsonElement ChangeFor(ImportPreviewItem item, string field)
|
|
{
|
|
foreach (var c in Changes(item).EnumerateArray())
|
|
{
|
|
if (c.GetProperty("field").GetString() == field)
|
|
{
|
|
return c.Clone();
|
|
}
|
|
}
|
|
|
|
Assert.Fail($"No change for field '{field}'. JSON: {item.FieldDiffJson}");
|
|
return default; // unreachable
|
|
}
|
|
|
|
private static IReadOnlyList<(string Op, string Text)> Hunks(JsonElement change)
|
|
{
|
|
var lineDiff = change.GetProperty("lineDiff");
|
|
var hunks = new List<(string, string)>();
|
|
foreach (var h in lineDiff.GetProperty("hunks").EnumerateArray())
|
|
{
|
|
hunks.Add((h.GetProperty("op").GetString()!, h.GetProperty("text").GetString()!));
|
|
}
|
|
|
|
return hunks;
|
|
}
|
|
|
|
// ============ T20: structured per-line code diff ============
|
|
|
|
[Fact]
|
|
public void SharedScript_ModifiedCode_EmitsStructuredLineDiff()
|
|
{
|
|
var existing = new SharedScript("calc", "var a = 1;\nvar b = 2;\nreturn a + b;");
|
|
var incoming = new SharedScriptDto(
|
|
Name: "calc",
|
|
Code: "var a = 1;\nvar b = 20;\nreturn a + b;",
|
|
ParameterDefinitions: existing.ParameterDefinitions,
|
|
ReturnDefinition: existing.ReturnDefinition);
|
|
|
|
var item = _diff.CompareSharedScript(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "Code");
|
|
// Compact summary retained for fallback.
|
|
Assert.Equal("<3 lines>", change.GetProperty("oldValue").GetString());
|
|
Assert.Equal("<3 lines>", change.GetProperty("newValue").GetString());
|
|
|
|
var lineDiff = change.GetProperty("lineDiff");
|
|
Assert.False(lineDiff.GetProperty("truncated").GetBoolean());
|
|
Assert.Equal(1, lineDiff.GetProperty("addedCount").GetInt32());
|
|
Assert.Equal(1, lineDiff.GetProperty("removedCount").GetInt32());
|
|
|
|
var hunks = Hunks(change);
|
|
// The changed middle line shows as a remove + add; unchanged lines are context.
|
|
Assert.Contains(("remove", "var b = 2;"), hunks);
|
|
Assert.Contains(("add", "var b = 20;"), hunks);
|
|
Assert.Contains(("context", "var a = 1;"), hunks);
|
|
Assert.Contains(("context", "return a + b;"), hunks);
|
|
// Not a "<N lines>"-only marker any more.
|
|
Assert.DoesNotContain(hunks, h => h.Text.Contains("lines>"));
|
|
}
|
|
|
|
[Fact]
|
|
public void SharedScript_IdenticalCode_NoChange()
|
|
{
|
|
var existing = new SharedScript("calc", "return 42;");
|
|
var incoming = new SharedScriptDto("calc", "return 42;", existing.ParameterDefinitions, existing.ReturnDefinition);
|
|
|
|
var item = _diff.CompareSharedScript(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
Assert.Null(item.FieldDiffJson);
|
|
}
|
|
|
|
// ============ Deferred-work row 27: ES retry policy in the diff ============
|
|
|
|
/// <summary>
|
|
/// The bundle has always CARRIED the external-system retry policy and
|
|
/// <c>BundleImporter</c> has always APPLIED it, but <c>CompareExternalSystem</c> never
|
|
/// compared it — so an import that silently changed a system's retry budget previewed as
|
|
/// "Identical". Now that the policy actually reaches sites, an unreviewed change to it is a
|
|
/// live behaviour change, so it must show up in the preview.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExternalSystem_ChangedRetryPolicy_IsReported()
|
|
{
|
|
var existing = new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
|
|
{
|
|
MaxRetries = 3,
|
|
RetryDelay = TimeSpan.FromSeconds(5),
|
|
};
|
|
var incoming = new ExternalSystemDto("Mes", "https://mes", "ApiKey",
|
|
MaxRetries: 9, RetryDelay: TimeSpan.FromSeconds(45), Methods: [], Secrets: null);
|
|
|
|
var item = _diff.CompareExternalSystem(incoming, existing, []);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
|
|
var maxRetries = ChangeFor(item, "MaxRetries");
|
|
Assert.Equal("3", maxRetries.GetProperty("oldValue").GetString());
|
|
Assert.Equal("9", maxRetries.GetProperty("newValue").GetString());
|
|
|
|
var retryDelay = ChangeFor(item, "RetryDelay");
|
|
Assert.Equal(TimeSpan.FromSeconds(5).ToString(), retryDelay.GetProperty("oldValue").GetString());
|
|
Assert.Equal(TimeSpan.FromSeconds(45).ToString(), retryDelay.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
/// <summary>An unchanged retry policy must not manufacture a spurious diff row.</summary>
|
|
[Fact]
|
|
public void ExternalSystem_UnchangedRetryPolicy_IsIdentical()
|
|
{
|
|
var existing = new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
|
|
{
|
|
MaxRetries = 3,
|
|
RetryDelay = TimeSpan.FromSeconds(5),
|
|
};
|
|
var incoming = new ExternalSystemDto("Mes", "https://mes", "ApiKey",
|
|
MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Methods: [], Secrets: null);
|
|
|
|
var item = _diff.CompareExternalSystem(incoming, existing, []);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
}
|
|
|
|
// ============ arch-review 2026-08-19 P7-1: TimeoutSeconds in the diff (row-27 shape) ============
|
|
|
|
/// <summary>
|
|
/// The bundle has always CARRIED TimeoutSeconds and BundleImporter has always APPLIED it
|
|
/// (the per-system HTTP timeout used by ExternalSystemClient), but CompareExternalSystem
|
|
/// never compared it — so an import whose ONLY change was a tuned timeout previewed as
|
|
/// "Identical" and the operator had no diff evidence the value was about to change.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExternalSystem_ChangedTimeoutSeconds_IsReported()
|
|
{
|
|
var existing = new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
|
|
{
|
|
TimeoutSeconds = 30,
|
|
};
|
|
var incoming = new ExternalSystemDto("Mes", "https://mes", "ApiKey",
|
|
MaxRetries: 0, RetryDelay: TimeSpan.Zero, Methods: [], Secrets: null, TimeoutSeconds: 120);
|
|
|
|
var item = _diff.CompareExternalSystem(incoming, existing, []);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
|
|
var timeout = ChangeFor(item, "TimeoutSeconds");
|
|
Assert.Equal("30", timeout.GetProperty("oldValue").GetString());
|
|
Assert.Equal("120", timeout.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
/// <summary>An unchanged timeout must not manufacture a spurious diff row.</summary>
|
|
[Fact]
|
|
public void ExternalSystem_UnchangedTimeoutSeconds_IsIdentical()
|
|
{
|
|
var existing = new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
|
|
{
|
|
TimeoutSeconds = 30,
|
|
};
|
|
var incoming = new ExternalSystemDto("Mes", "https://mes", "ApiKey",
|
|
MaxRetries: 0, RetryDelay: TimeSpan.Zero, Methods: [], Secrets: null, TimeoutSeconds: 30);
|
|
|
|
var item = _diff.CompareExternalSystem(incoming, existing, []);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Old-bundle semantics: a bundle written before this field existed carries
|
|
/// <c>TimeoutSeconds: null</c>. The diff must treat that as "not specified by this
|
|
/// bundle" — same as BundleImporter's Overwrite fallback — rather than reading it as
|
|
/// "reset to 0/unset" and manufacturing a spurious change.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExternalSystem_MissingTimeoutSeconds_ComparesAsUnchanged()
|
|
{
|
|
var existing = new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
|
|
{
|
|
TimeoutSeconds = 30,
|
|
};
|
|
var incoming = new ExternalSystemDto("Mes", "https://mes", "ApiKey",
|
|
MaxRetries: 0, RetryDelay: TimeSpan.Zero, Methods: [], Secrets: null, TimeoutSeconds: null);
|
|
|
|
var item = _diff.CompareExternalSystem(incoming, existing, []);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApiMethod_ModifiedScript_EmitsStructuredLineDiff()
|
|
{
|
|
var existing = new ApiMethod("doThing", "log(\"a\");") { TimeoutSeconds = 30 };
|
|
var incoming = new ApiMethodDto(
|
|
Name: "doThing",
|
|
Script: "log(\"a\");\nlog(\"b\");",
|
|
ParameterDefinitions: existing.ParameterDefinitions,
|
|
ReturnDefinition: existing.ReturnDefinition,
|
|
TimeoutSeconds: 30);
|
|
|
|
var item = _diff.CompareApiMethod(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "Script");
|
|
var lineDiff = change.GetProperty("lineDiff");
|
|
Assert.Equal(1, lineDiff.GetProperty("addedCount").GetInt32());
|
|
Assert.Equal(0, lineDiff.GetProperty("removedCount").GetInt32());
|
|
|
|
var hunks = Hunks(change);
|
|
Assert.Contains(("context", "log(\"a\");"), hunks);
|
|
Assert.Contains(("add", "log(\"b\");"), hunks);
|
|
}
|
|
|
|
[Fact]
|
|
public void TemplateScript_ModifiedCode_EmitsStructuredLineDiff()
|
|
{
|
|
var existing = MakeTemplate("T1");
|
|
existing.Scripts.Add(new TemplateScript("onScan", "old1\nold2"));
|
|
|
|
var incoming = MakeTemplateDto("T1", scripts:
|
|
[
|
|
new TemplateScriptDto("onScan", "old1\nnew2", null, null, null, null, false, null),
|
|
]);
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "Scripts.onScan");
|
|
var hunks = Hunks(change);
|
|
Assert.Contains(("context", "old1"), hunks);
|
|
Assert.Contains(("remove", "old2"), hunks);
|
|
Assert.Contains(("add", "new2"), hunks);
|
|
}
|
|
|
|
// ============ #05-T23: placeholder-identity resolution via name maps ============
|
|
|
|
[Fact]
|
|
public void CompareTemplate_MatchingFolderBaseAndComposition_IsIdentical()
|
|
{
|
|
// A structured template (FolderId, ParentTemplateId, one composition) that
|
|
// the incoming bundle matches by NAME must classify Identical. Before T23,
|
|
// FolderNameOf/BaseTemplateNameOf/CompositionTargetNameOf returned "<id:N>"
|
|
// placeholders that never equalled the incoming names, so every re-import
|
|
// of an unchanged structured template spuriously read Modified.
|
|
var existing = MakeTemplate("Pump");
|
|
existing.FolderId = 5;
|
|
existing.ParentTemplateId = 7;
|
|
existing.Compositions.Add(new TemplateComposition("Mod1") { ComposedTemplateId = 9 });
|
|
|
|
var folderNameById = new Dictionary<int, string> { [5] = "Machines" };
|
|
var templateNameById = new Dictionary<int, string> { [7] = "BasePump", [9] = "FeatureX" };
|
|
|
|
var incoming = MakeTemplateDto("Pump") with
|
|
{
|
|
FolderName = "Machines",
|
|
BaseTemplateName = "BasePump",
|
|
Compositions = new[] { new TemplateCompositionDto("Mod1", "FeatureX") },
|
|
};
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing, folderNameById, templateNameById);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareTemplate_DivergentBaseTemplateName_IsModified()
|
|
{
|
|
// Sanity: the maps must not mask a REAL change. Existing base = "BasePump",
|
|
// incoming names a different base → Modified.
|
|
var existing = MakeTemplate("Pump");
|
|
existing.ParentTemplateId = 7;
|
|
var templateNameById = new Dictionary<int, string> { [7] = "BasePump" };
|
|
|
|
var incoming = MakeTemplateDto("Pump") with { BaseTemplateName = "DifferentBase" };
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing, folderNameById: null, templateNameById: templateNameById);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
}
|
|
|
|
// ============ #05-T6: single-source child equality — diff sees the full field set ============
|
|
|
|
[Fact]
|
|
public void CompareTemplate_AttributeElementDataTypeChange_IsModified()
|
|
{
|
|
var existing = MakeTemplate("T1");
|
|
existing.Attributes.Add(new TemplateAttribute("L")
|
|
{
|
|
DataType = DataType.List,
|
|
ElementDataType = DataType.Int32,
|
|
Value = "[1,2]",
|
|
});
|
|
var incoming = MakeTemplateDto("T1", attributes:
|
|
[
|
|
new TemplateAttributeDto("L", "[1,2]", DataType.List, false, null, null, DataType.String),
|
|
]);
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
ChangeFor(item, "Attributes.L"); // ElementDataType diverged — previously missed.
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareTemplate_LockedInDerivedChange_IsModified()
|
|
{
|
|
var existing = MakeTemplate("T1");
|
|
existing.Attributes.Add(new TemplateAttribute("L")
|
|
{
|
|
DataType = DataType.Int32,
|
|
Value = "1",
|
|
LockedInDerived = false,
|
|
});
|
|
var incoming = MakeTemplateDto("T1", attributes:
|
|
[
|
|
new TemplateAttributeDto("L", "1", DataType.Int32, false, null, null, null, LockedInDerived: true),
|
|
]);
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
ChangeFor(item, "Attributes.L"); // LockedInDerived diverged — previously missed.
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified()
|
|
{
|
|
var existing = MakeTemplate("T1");
|
|
existing.Scripts.Add(new TemplateScript("S", "return 1;") { ExecutionTimeoutSeconds = 30 });
|
|
var incoming = MakeTemplateDto("T1", scripts:
|
|
[
|
|
// identical code; only ExecutionTimeoutSeconds differs.
|
|
new TemplateScriptDto("S", "return 1;", null, null, null, null, false, null, ExecutionTimeoutSeconds: 60),
|
|
]);
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
ChangeFor(item, "Scripts.S"); // ExecutionTimeoutSeconds diverged — previously missed.
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified()
|
|
{
|
|
var existing = MakeTemplate("T1");
|
|
existing.Scripts.Add(new TemplateScript("Run", "return 1;") { Id = 1 });
|
|
existing.Alarms.Add(new TemplateAlarm("A")
|
|
{
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
OnTriggerScriptId = 1, // resolves to "Run"
|
|
});
|
|
var incoming = MakeTemplateDto("T1",
|
|
scripts: [new TemplateScriptDto("Run", "return 1;", null, null, null, null, false, null)],
|
|
alarms:
|
|
[
|
|
// same alarm fields but no on-trigger script (was "Run").
|
|
new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, OnTriggerScriptName: null),
|
|
]);
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed.
|
|
}
|
|
|
|
// ============ M8: CompareSite ============
|
|
|
|
[Fact]
|
|
public void CompareSite_ExistingNull_New()
|
|
{
|
|
var dto = new SiteDto("site-a", "Site A", null, null, null, null, null);
|
|
var item = _diff.CompareSite(dto, existing: null);
|
|
|
|
Assert.Equal(ConflictKind.New, item.Kind);
|
|
Assert.Equal("Site", item.EntityType);
|
|
Assert.Equal("site-a", item.Name);
|
|
Assert.Null(item.FieldDiffJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareSite_FieldDiffers_Modified()
|
|
{
|
|
var existing = new Site("Site A", "site-a") { Description = "old", NodeAAddress = "10.0.0.1" };
|
|
var dto = new SiteDto("site-a", "Site A", "new", "10.0.0.1", null, null, null);
|
|
|
|
var item = _diff.CompareSite(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "Description");
|
|
Assert.Equal("old", change.GetProperty("oldValue").GetString());
|
|
Assert.Equal("new", change.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareSite_NoChanges_Identical()
|
|
{
|
|
var existing = new Site("Site A", "site-a")
|
|
{
|
|
Description = "d",
|
|
NodeAAddress = "a",
|
|
NodeBAddress = "b",
|
|
GrpcNodeAAddress = "ga",
|
|
GrpcNodeBAddress = "gb",
|
|
};
|
|
var dto = new SiteDto("site-a", "Site A", "d", "a", "b", "ga", "gb");
|
|
|
|
var item = _diff.CompareSite(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
Assert.Null(item.FieldDiffJson);
|
|
}
|
|
|
|
// ============ M8: CompareDataConnection ============
|
|
|
|
[Fact]
|
|
public void CompareDataConnection_ExistingNull_New()
|
|
{
|
|
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, null);
|
|
var item = _diff.CompareDataConnection(dto, existing: null);
|
|
|
|
Assert.Equal(ConflictKind.New, item.Kind);
|
|
Assert.Equal("DataConnection", item.EntityType);
|
|
Assert.Equal("opc1", item.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareDataConnection_ProtocolDiffers_Modified()
|
|
{
|
|
var existing = new DataConnection("opc1", "OpcUa", siteId: 1) { FailoverRetryCount = 3 };
|
|
var dto = new DataConnectionDto("site-a", "opc1", "MxAccess", 3, null);
|
|
|
|
var item = _diff.CompareDataConnection(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "Protocol");
|
|
Assert.Equal("OpcUa", change.GetProperty("oldValue").GetString());
|
|
Assert.Equal("MxAccess", change.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareDataConnection_SecretValueChangesButPresencePreserved_NoSecretEcho()
|
|
{
|
|
// Both sides HAVE a primary configuration secret; only the value differs.
|
|
// Presence-only comparison => no Secrets.* change, and the value is never echoed.
|
|
var existing = new DataConnection("opc1", "OpcUa", siteId: 1)
|
|
{
|
|
FailoverRetryCount = 3,
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://OLD:4840\"}",
|
|
};
|
|
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3,
|
|
new SecretsBlock(new Dictionary<string, string>
|
|
{
|
|
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://NEW:4840\"}",
|
|
}));
|
|
|
|
var item = _diff.CompareDataConnection(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
Assert.Null(item.FieldDiffJson);
|
|
// The secret value must never appear anywhere in the diff output.
|
|
Assert.DoesNotContain("4840", item.FieldDiffJson ?? string.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareDataConnection_SecretPresenceFlips_ShowsPresentMarkerOnly()
|
|
{
|
|
// Existing has a primary config; incoming carries no secret => presence change.
|
|
var existing = new DataConnection("opc1", "OpcUa", siteId: 1)
|
|
{
|
|
FailoverRetryCount = 3,
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://h:4840\"}",
|
|
};
|
|
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, Secrets: null);
|
|
|
|
var item = _diff.CompareDataConnection(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "Secrets.PrimaryConfiguration");
|
|
Assert.Equal("<present>", change.GetProperty("oldValue").GetString());
|
|
// newValue is null and omitted from JSON by the WhenWritingNull policy.
|
|
Assert.False(change.TryGetProperty("newValue", out _));
|
|
Assert.DoesNotContain("4840", item.FieldDiffJson!);
|
|
}
|
|
|
|
// ============ SMS: CompareSmsConfiguration (S10b) ============
|
|
|
|
private static SmsConfiguration ExistingSms() =>
|
|
new("AC1", "+15550000000") { Id = 1, AuthToken = "stored-token" };
|
|
|
|
private static SmsConfigDto SmsDto(
|
|
string from = "+15550000000",
|
|
string? msgSid = null,
|
|
bool withSecret = true) =>
|
|
new("AC1", from, msgSid, ApiBaseUrl: null, ConnectionTimeoutSeconds: 30,
|
|
MaxRetries: 10, RetryDelay: TimeSpan.FromMinutes(1),
|
|
Secrets: withSecret
|
|
? new SecretsBlock(new Dictionary<string, string> { ["AuthToken"] = "wire-token" })
|
|
: null);
|
|
|
|
[Fact]
|
|
public void CompareSmsConfiguration_ExistingNull_New()
|
|
{
|
|
var item = _diff.CompareSmsConfiguration(SmsDto(), existing: null);
|
|
|
|
Assert.Equal(ConflictKind.New, item.Kind);
|
|
Assert.Equal("SmsConfiguration", item.EntityType);
|
|
Assert.Equal("AC1", item.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareSmsConfiguration_AllFieldsAndPresenceMatch_Identical()
|
|
{
|
|
var item = _diff.CompareSmsConfiguration(SmsDto(), ExistingSms());
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
Assert.Null(item.FieldDiffJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareSmsConfiguration_FromNumberDiffers_Modified()
|
|
{
|
|
var item = _diff.CompareSmsConfiguration(SmsDto(from: "+15559999999"), ExistingSms());
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "FromNumber");
|
|
Assert.Equal("+15550000000", change.GetProperty("oldValue").GetString());
|
|
Assert.Equal("+15559999999", change.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareSmsConfiguration_AuthTokenValueChangesButPresencePreserved_NoSecretEcho()
|
|
{
|
|
// Both sides carry a token; only the value differs. Presence-only comparison =>
|
|
// no Secrets.AuthToken change, and neither token value is ever echoed.
|
|
var item = _diff.CompareSmsConfiguration(SmsDto(withSecret: true), ExistingSms());
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
var json = item.FieldDiffJson ?? string.Empty;
|
|
Assert.DoesNotContain("stored-token", json);
|
|
Assert.DoesNotContain("wire-token", json);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareSmsConfiguration_AuthTokenPresenceFlips_ShowsPresentMarkerOnly()
|
|
{
|
|
// Existing has a token; incoming carries no secret => a presence change that
|
|
// surfaces only the "<present>" marker, never the token value.
|
|
var item = _diff.CompareSmsConfiguration(SmsDto(withSecret: false), ExistingSms());
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "Secrets.AuthToken");
|
|
Assert.Equal("<present>", change.GetProperty("oldValue").GetString());
|
|
Assert.False(change.TryGetProperty("newValue", out _));
|
|
Assert.DoesNotContain("stored-token", item.FieldDiffJson!);
|
|
}
|
|
|
|
// ============ M8: CompareInstance ============
|
|
|
|
[Fact]
|
|
public void CompareInstance_ExistingNull_New()
|
|
{
|
|
var dto = MakeInstanceDto("inst-1");
|
|
var item = _diff.CompareInstance(dto, existing: null);
|
|
|
|
Assert.Equal(ConflictKind.New, item.Kind);
|
|
Assert.Equal("Instance", item.EntityType);
|
|
Assert.Equal("inst-1", item.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareInstance_StateDiffers_Modified()
|
|
{
|
|
var existing = new Instance("inst-1") { State = InstanceState.Disabled };
|
|
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled);
|
|
|
|
var item = _diff.CompareInstance(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var change = ChangeFor(item, "State");
|
|
Assert.Equal("Disabled", change.GetProperty("oldValue").GetString());
|
|
Assert.Equal("Enabled", change.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareInstance_NoChanges_Identical()
|
|
{
|
|
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
|
|
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled);
|
|
|
|
var item = _diff.CompareInstance(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
Assert.Null(item.FieldDiffJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareInstance_TemplateAndSiteNamesDiffer_Modified()
|
|
{
|
|
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
|
|
var dto = MakeInstanceDto("inst-1", template: "TmplB", site: "site-b", state: InstanceState.Enabled);
|
|
|
|
var item = _diff.CompareInstance(dto, existing,
|
|
existingTemplateName: "TmplA", existingSiteIdentifier: "site-a");
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
Assert.Equal("TmplA", ChangeFor(item, "TemplateName").GetProperty("oldValue").GetString());
|
|
Assert.Equal("TmplB", ChangeFor(item, "TemplateName").GetProperty("newValue").GetString());
|
|
Assert.Equal("site-a", ChangeFor(item, "SiteIdentifier").GetProperty("oldValue").GetString());
|
|
Assert.Equal("site-b", ChangeFor(item, "SiteIdentifier").GetProperty("newValue").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void CompareInstance_ChildOverridesAddedRemovedModified()
|
|
{
|
|
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
|
|
// Will be REMOVED (present existing, absent incoming).
|
|
existing.AttributeOverrides.Add(new InstanceAttributeOverride("Setpoint") { OverrideValue = "10" });
|
|
// Will be MODIFIED (present both, value differs).
|
|
existing.AttributeOverrides.Add(new InstanceAttributeOverride("Scale") { OverrideValue = "1.0" });
|
|
|
|
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, attributeOverrides:
|
|
[
|
|
new InstanceAttributeOverrideDto("Scale", "2.0", null), // modified
|
|
new InstanceAttributeOverrideDto("Offset", "5", null), // added
|
|
]);
|
|
|
|
var item = _diff.CompareInstance(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
|
|
var removed = ChangeFor(item, "AttributeOverrides.Setpoint");
|
|
Assert.Equal("<present>", removed.GetProperty("oldValue").GetString());
|
|
Assert.False(removed.TryGetProperty("newValue", out _));
|
|
|
|
var added = ChangeFor(item, "AttributeOverrides.Offset");
|
|
Assert.False(added.TryGetProperty("oldValue", out _));
|
|
Assert.Equal("<added>", added.GetProperty("newValue").GetString());
|
|
|
|
var modified = ChangeFor(item, "AttributeOverrides.Scale");
|
|
Assert.Equal("<modified>", modified.GetProperty("oldValue").GetString());
|
|
Assert.Equal("<modified>", modified.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
// ---- builders ----
|
|
|
|
private static Commons.Entities.Templates.Template MakeTemplate(string name) =>
|
|
new(name);
|
|
|
|
private static TemplateDto MakeTemplateDto(
|
|
string name,
|
|
IReadOnlyList<TemplateScriptDto>? scripts = null,
|
|
IReadOnlyList<TemplateAttributeDto>? attributes = null,
|
|
IReadOnlyList<TemplateAlarmDto>? alarms = null) =>
|
|
new(
|
|
Name: name,
|
|
FolderName: null,
|
|
BaseTemplateName: null,
|
|
Description: null,
|
|
Attributes: attributes ?? Array.Empty<TemplateAttributeDto>(),
|
|
Alarms: alarms ?? Array.Empty<TemplateAlarmDto>(),
|
|
Scripts: scripts ?? Array.Empty<TemplateScriptDto>(),
|
|
Compositions: Array.Empty<TemplateCompositionDto>());
|
|
|
|
private static InstanceDto MakeInstanceDto(
|
|
string uniqueName,
|
|
string template = "Tmpl",
|
|
string site = "site-a",
|
|
InstanceState state = InstanceState.Enabled,
|
|
IReadOnlyList<InstanceAttributeOverrideDto>? attributeOverrides = null) =>
|
|
new(
|
|
UniqueName: uniqueName,
|
|
TemplateName: template,
|
|
SiteIdentifier: site,
|
|
AreaName: null,
|
|
State: state,
|
|
AttributeOverrides: attributeOverrides ?? Array.Empty<InstanceAttributeOverrideDto>(),
|
|
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
|
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
|
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>());
|
|
}
|