513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
791 lines
34 KiB
C#
791 lines
34 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 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 ============
|
|
|
|
/// <summary>Verifies that a modified shared script emits a structured line diff.</summary>
|
|
[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>"));
|
|
}
|
|
|
|
/// <summary>Verifies that a shared script with identical code reports no change.</summary>
|
|
[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);
|
|
}
|
|
|
|
// ============ TimeoutSeconds in the diff ============
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <summary>Verifies that a modified API method script emits a structured line diff.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that modified template script code emits a structured line diff.</summary>
|
|
[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);
|
|
}
|
|
|
|
// ============ placeholder-identity resolution via name maps ============
|
|
|
|
/// <summary>Verifies that templates with matching folder, base and composition names compare as identical.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a divergent base template name makes the template compare as modified.</summary>
|
|
[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);
|
|
}
|
|
|
|
// ============ single-source child equality — diff sees the full field set ============
|
|
|
|
/// <summary>Verifies that a change to an attribute's element data type makes the template compare as modified.</summary>
|
|
[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.
|
|
}
|
|
|
|
/// <summary>Verifies that a change to the locked-in-derived flag makes the template compare as modified.</summary>
|
|
[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.
|
|
}
|
|
|
|
/// <summary>Verifies that a change to a script's execution timeout makes the template compare as modified.</summary>
|
|
[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.
|
|
}
|
|
|
|
/// <summary>Verifies that a change to an alarm's on-trigger script makes the template compare as modified.</summary>
|
|
[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.
|
|
}
|
|
|
|
/// <summary>Verifies that a change to an alarm's MES-reportable flag makes the template compare as modified.</summary>
|
|
[Fact]
|
|
public void CompareTemplate_AlarmIsMesReportableChange_IsModified()
|
|
{
|
|
var existing = MakeTemplate("T1");
|
|
existing.Alarms.Add(new TemplateAlarm("A")
|
|
{
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
IsMesReportable = false,
|
|
});
|
|
var incoming = MakeTemplateDto("T1", alarms:
|
|
[
|
|
// every other alarm field identical; only IsMesReportable differs.
|
|
new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, null,
|
|
IsMesReportable: true),
|
|
]);
|
|
|
|
var item = _diff.CompareTemplate(incoming, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
ChangeFor(item, "Alarms.A"); // IsMesReportable diverged.
|
|
}
|
|
|
|
/// <summary>Verifies that a change to an instance alarm override's MES-reportable flag makes the instance compare as modified.</summary>
|
|
[Fact]
|
|
public void CompareInstance_AlarmOverrideIsMesReportableChange_IsModified()
|
|
{
|
|
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
|
|
existing.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
|
|
{
|
|
TriggerConfigurationOverride = "{\"hi\":90}",
|
|
PriorityLevelOverride = 7,
|
|
IsMesReportableOverride = null,
|
|
});
|
|
|
|
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, alarmOverrides:
|
|
[
|
|
// identical apart from the MES-reportable override.
|
|
new InstanceAlarmOverrideDto("HiAlarm", "{\"hi\":90}", 7, IsMesReportableOverride: false),
|
|
]);
|
|
|
|
var item = _diff.CompareInstance(dto, existing);
|
|
|
|
Assert.Equal(ConflictKind.Modified, item.Kind);
|
|
var modified = ChangeFor(item, "AlarmOverrides.HiAlarm");
|
|
Assert.Equal("<modified>", modified.GetProperty("oldValue").GetString());
|
|
Assert.Equal("<modified>", modified.GetProperty("newValue").GetString());
|
|
}
|
|
|
|
// ============ CompareSite ============
|
|
|
|
/// <summary>Verifies that comparing a site against no existing site yields a new result.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a site with a differing field compares as modified.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that a site with no changes compares as identical.</summary>
|
|
[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);
|
|
}
|
|
|
|
// ============ CompareDataConnection ============
|
|
|
|
/// <summary>Verifies that comparing a data connection against no existing connection yields a new result.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a data connection with a different protocol compares as modified.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that a changed secret value with preserved presence is reported without echoing the secret.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a flip in secret presence is shown only as a present marker, without the secret value.</summary>
|
|
[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);
|
|
|
|
/// <summary>Verifies that comparing an SMS configuration against no existing configuration yields a new result.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an SMS configuration whose fields and secret presence all match compares as identical.</summary>
|
|
[Fact]
|
|
public void CompareSmsConfiguration_AllFieldsAndPresenceMatch_Identical()
|
|
{
|
|
var item = _diff.CompareSmsConfiguration(SmsDto(), ExistingSms());
|
|
|
|
Assert.Equal(ConflictKind.Identical, item.Kind);
|
|
Assert.Null(item.FieldDiffJson);
|
|
}
|
|
|
|
/// <summary>Verifies that an SMS configuration with a different from-number compares as modified.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that a changed auth token value with preserved presence is reported without echoing the secret.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a flip in auth token presence is shown only as a present marker, without the secret value.</summary>
|
|
[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!);
|
|
}
|
|
|
|
// ============ CompareInstance ============
|
|
|
|
/// <summary>Verifies that comparing an instance against no existing instance yields a new result.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an instance with a differing state compares as modified.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that an instance with no changes compares as identical.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that differing template and site names make the instance compare as modified.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that added, removed and modified child overrides are reported for an instance.</summary>
|
|
[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,
|
|
IReadOnlyList<InstanceAlarmOverrideDto>? alarmOverrides = null) =>
|
|
new(
|
|
UniqueName: uniqueName,
|
|
TemplateName: template,
|
|
SiteIdentifier: site,
|
|
AreaName: null,
|
|
State: state,
|
|
AttributeOverrides: attributeOverrides ?? Array.Empty<InstanceAttributeOverrideDto>(),
|
|
AlarmOverrides: alarmOverrides ?? Array.Empty<InstanceAlarmOverrideDto>(),
|
|
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
|
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>());
|
|
}
|