fix(m9/T24a): scope move-guard native-alarm scan to source-site templates (Ordinal); purpose-built include; add guard-4 + repo tests

This commit is contained in:
Joseph Doherty
2026-06-18 11:38:31 -04:00
parent fbe4ddaf58
commit dbe51e5f25
5 changed files with 290 additions and 15 deletions
@@ -776,6 +776,76 @@ public class SiteRepositoryTests : IDisposable
Assert.Single(instances);
}
[Fact]
public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty()
{
var site = new Site("Site1", "S-001");
var template = new Template("T1");
_context.Sites.Add(site);
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
_context.DataConnections.Add(conn);
_context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
await _context.SaveChangesAsync();
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
Assert.Empty(result);
}
[Fact]
public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance()
{
var site = new Site("Site1", "S-001");
var template = new Template("T1");
_context.Sites.Add(site);
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
_context.DataConnections.Add(conn);
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
_context.Instances.Add(instance);
await _context.SaveChangesAsync();
_context.InstanceConnectionBindings.Add(
new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
await _context.SaveChangesAsync();
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
Assert.Single(result);
Assert.Equal(instance.Id, result[0].Id);
}
[Fact]
public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne()
{
var site = new Site("Site1", "S-001");
var template = new Template("T1");
_context.Sites.Add(site);
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
_context.DataConnections.Add(conn);
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
_context.Instances.Add(instance);
await _context.SaveChangesAsync();
// Two distinct bindings (different attributes) on the SAME instance both
// reference the connection -> the result must be deduped to one instance.
_context.InstanceConnectionBindings.Add(
new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
_context.InstanceConnectionBindings.Add(
new InstanceConnectionBinding("Attr2") { InstanceId = instance.Id, DataConnectionId = conn.Id });
await _context.SaveChangesAsync();
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
Assert.Single(result);
Assert.Equal(instance.Id, result[0].Id);
}
[Fact]
public void Constructor_NullContext_Throws()
{
@@ -2104,18 +2104,14 @@ public class ManagementActorTests : TestKit, IDisposable
// No bindings reference the connection -> move allowed.
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
// No native-alarm-source overrides on the source site.
siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
// No native-alarm-source overrides on the source site (purpose-built load).
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped(_ => siteRepo);
// No templates reference the connection by name.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
@@ -2213,4 +2209,173 @@ public class ManagementActorTests : TestKit, IDisposable
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_SameSite_ReturnsErrorNoWrite()
{
// Moving a connection to the site it already belongs to is a no-op and
// must be rejected before any write — and before the existence/collision
// guards even run.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
_services.AddScoped(_ => siteRepo);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 1), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("already belongs", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
siteRepo.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_BlockedByTemplateNativeAlarmSourceOnSourceSite_ReturnsError()
{
// A template instantiated on the SOURCE site has a native-alarm-source
// whose ConnectionName matches the moving connection -> blocked, because
// the deploying-site flatten would no longer resolve that name.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
// Source-site instance referencing template 42.
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
var template = new Template("PumpTemplate") { Id = 42 };
template.NativeAlarmSources.Add(
new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
// Source-site instances (purpose-built load with overrides eagerly loaded).
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { sourceInstance });
_services.AddScoped(_ => siteRepo);
// Only template 42 (instantiated on the source site) is scanned.
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(template);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
// Error names the template and its native-alarm-source.
Assert.Contains("PumpTemplate", response.Error);
Assert.Contains("PumpFaults", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_BlockedByInstanceOverrideOnSourceSite_ReturnsError()
{
// A source-site instance has a native-alarm-source override whose
// ConnectionNameOverride matches the moving connection -> blocked.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
sourceInstance.NativeAlarmSourceOverrides.Add(
new InstanceNativeAlarmSourceOverride("PumpFaults")
{ Id = 1, InstanceId = 7, ConnectionNameOverride = "Opc1" });
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { sourceInstance });
_services.AddScoped(_ => siteRepo);
// Template 42 has no matching native-alarm-source; the override is the blocker.
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>())
.Returns(new Template("PumpTemplate") { Id = 42 });
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
// Error names the instance and its override.
Assert.Contains("Tank01", response.Error);
Assert.Contains("PumpFaults", response.Error);
Assert.Equal(1, conn.SiteId);
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
}
[Fact]
public void MoveDataConnection_TemplateReferencedOnlyByOtherSite_DoesNotBlock()
{
// Regression for the over-blocking false positive: a template references
// the connection NAME ("Opc1") but is instantiated ONLY on another site.
// Templates are site-agnostic and ConnectionName resolves against the
// DEPLOYING site's pool, so moving site 1's "Opc1" does not break the
// other site's deployment. The move must succeed.
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
// The SOURCE site (1) has an instance using template 99, which does NOT
// reference "Opc1". A different template (42) referencing "Opc1" exists
// but is instantiated only on the target/other site, so it is never loaded.
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 99 };
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.DataConnection>());
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Instance>());
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { sourceInstance });
siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped(_ => siteRepo);
// Template 99 (the one actually on the source site) does not reference "Opc1".
_templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
.Returns(new Template("OtherTemplate") { Id = 99 });
// Template 42 references "Opc1" but lives only on another site: if the
// handler scoped its scan correctly it must NEVER load template 42. We
// still stub it to prove the cross-site reference is irrelevant.
var crossSiteTemplate = new Template("PumpTemplate") { Id = 42 };
crossSiteTemplate.NativeAlarmSources.Add(
new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(crossSiteTemplate);
var actor = CreateActor();
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
actor.Tell(envelope);
// Move SUCCEEDS: the cross-site template reference must not block.
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(2, conn.SiteId);
siteRepo.Received(1).UpdateDataConnectionAsync(
Arg.Is<Commons.Entities.Sites.DataConnection>(c => c.Id == 5 && c.SiteId == 2),
Arg.Any<CancellationToken>());
// The off-site template (42) must never be scanned.
_templateRepo.DidNotReceive().GetTemplateByIdAsync(42, Arg.Any<CancellationToken>());
}
}