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:
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user