feat(m9/T24a): guarded move-data-connection-between-sites command + handler
This commit is contained in:
@@ -70,13 +70,26 @@ public interface ISiteRepository
|
|||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
|
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
// Instances (for deletion constraint checks)
|
// Instances (for deletion / move constraint checks)
|
||||||
/// <summary>Retrieves all instances deployed to a site.</summary>
|
/// <summary>Retrieves all instances deployed to a site.</summary>
|
||||||
/// <param name="siteId">The site primary key to filter by.</param>
|
/// <param name="siteId">The site primary key to filter by.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that resolves to a read-only list of <see cref="Instance"/> entities for the given site.</returns>
|
/// <returns>A task that resolves to a read-only list of <see cref="Instance"/> entities for the given site.</returns>
|
||||||
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the distinct instances that have at least one
|
||||||
|
/// <see cref="InstanceConnectionBinding"/> referencing the given data
|
||||||
|
/// connection. Used as the primary data-integrity guard for moving a
|
||||||
|
/// connection between sites: a bound connection cannot leave its site
|
||||||
|
/// without orphaning the (site-scoped) binding, so the move handler rejects
|
||||||
|
/// the move and names the returned instances as blockers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dataConnectionId">The data connection primary key.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that resolves to the distinct referencing <see cref="Instance"/> entities (empty when none).</returns>
|
||||||
|
Task<IReadOnlyList<Instance>> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Saves all pending changes to the database.</summary>
|
/// <summary>Saves all pending changes to the database.</summary>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that resolves to the number of state entries written to the database.</returns>
|
/// <returns>A task that resolves to the number of state entries written to the database.</returns>
|
||||||
|
|||||||
@@ -5,3 +5,18 @@ public record GetDataConnectionCommand(int DataConnectionId);
|
|||||||
public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3);
|
public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3);
|
||||||
public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3);
|
public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3);
|
||||||
public record DeleteDataConnectionCommand(int DataConnectionId);
|
public record DeleteDataConnectionCommand(int DataConnectionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a data connection from its current site to <paramref name="TargetSiteId"/>.
|
||||||
|
/// Designer-gated and heavily guarded (see the handler): the target site must
|
||||||
|
/// exist, the target site must not already own a connection with the same name,
|
||||||
|
/// and NO <see cref="Entities.Instances.InstanceConnectionBinding"/> may reference
|
||||||
|
/// the connection (instances are site-scoped, so a bound connection cannot leave
|
||||||
|
/// its site without orphaning the binding). Name-based native-alarm-source
|
||||||
|
/// references (template <c>ConnectionName</c> / instance
|
||||||
|
/// <c>ConnectionNameOverride</c>) are also checked and the move is blocked rather
|
||||||
|
/// than silently creating an ambiguous/orphaned state. No auto-rewrite is performed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DataConnectionId">Primary key of the connection to move.</param>
|
||||||
|
/// <param name="TargetSiteId">Primary key of the destination site.</param>
|
||||||
|
public record MoveDataConnectionCommand(int DataConnectionId, int TargetSiteId);
|
||||||
|
|||||||
@@ -125,13 +125,37 @@ public class SiteRepository : ISiteRepository
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Instances (for deletion constraint checks) ---
|
// --- Instances (for deletion / move constraint checks) ---
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbContext.Instances
|
return await _dbContext.Instances
|
||||||
.Where(i => i.SiteId == siteId)
|
.Where(i => i.SiteId == siteId)
|
||||||
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.Include(i => i.NativeAlarmSourceOverrides)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<Instance>> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// The distinct instance ids whose bindings reference the connection.
|
||||||
|
// Bindings are not exposed via their own DbSet on ISiteRepository, so
|
||||||
|
// resolve them through the InstanceConnectionBindings set, then load the
|
||||||
|
// owning instances (cheap; the blocker list is expected to be tiny).
|
||||||
|
var instanceIds = await _dbContext.InstanceConnectionBindings
|
||||||
|
.Where(b => b.DataConnectionId == dataConnectionId)
|
||||||
|
.Select(b => b.InstanceId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (instanceIds.Count == 0)
|
||||||
|
return Array.Empty<Instance>();
|
||||||
|
|
||||||
|
return await _dbContext.Instances
|
||||||
|
.Where(i => instanceIds.Contains(i.Id))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
or DeleteNotificationListCommand
|
or DeleteNotificationListCommand
|
||||||
or UpdateSmtpConfigCommand
|
or UpdateSmtpConfigCommand
|
||||||
or CreateDataConnectionCommand or UpdateDataConnectionCommand
|
or CreateDataConnectionCommand or UpdateDataConnectionCommand
|
||||||
or DeleteDataConnectionCommand
|
or DeleteDataConnectionCommand or MoveDataConnectionCommand
|
||||||
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
|
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
|
||||||
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
|
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
|
||||||
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
||||||
@@ -306,6 +306,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
|
CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
|
||||||
UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
|
UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
|
||||||
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
|
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
|
||||||
|
MoveDataConnectionCommand cmd => await HandleMoveDataConnection(sp, cmd, user.Username),
|
||||||
|
|
||||||
// External Systems
|
// External Systems
|
||||||
ListExternalSystemsCommand => await HandleListExternalSystems(sp),
|
ListExternalSystemsCommand => await HandleListExternalSystems(sp),
|
||||||
@@ -1373,6 +1374,108 @@ public class ManagementActor : ReceiveActor
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a data connection to another site (M9 / T24a). High-risk + data
|
||||||
|
/// integrity: every guard runs server-side BEFORE the write, and when in
|
||||||
|
/// doubt the move is BLOCKED with a clear error rather than risking an
|
||||||
|
/// orphaned binding/reference. No bindings or name references are rewritten.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<object?> HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user)
|
||||||
|
{
|
||||||
|
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||||
|
|
||||||
|
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
|
||||||
|
?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
|
||||||
|
|
||||||
|
var sourceSiteId = conn.SiteId;
|
||||||
|
if (sourceSiteId == cmd.TargetSiteId)
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"DataConnection '{conn.Name}' (ID {conn.Id}) already belongs to site {cmd.TargetSiteId}.");
|
||||||
|
|
||||||
|
// Guard 1: target site must exist.
|
||||||
|
var targetSite = await repo.GetSiteByIdAsync(cmd.TargetSiteId)
|
||||||
|
?? throw new ManagementCommandException($"Target site with ID {cmd.TargetSiteId} not found.");
|
||||||
|
|
||||||
|
// Guard 2: no name collision at the target site.
|
||||||
|
var targetConnections = await repo.GetDataConnectionsBySiteIdAsync(cmd.TargetSiteId);
|
||||||
|
if (targetConnections.Any(c => c.Id != conn.Id
|
||||||
|
&& string.Equals(c.Name, conn.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"Target site '{targetSite.Name}' (ID {cmd.TargetSiteId}) already has a data connection named '{conn.Name}'. " +
|
||||||
|
"Rename one of them before moving.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 3 (primary data-integrity guard): no InstanceConnectionBinding may
|
||||||
|
// reference the connection. Instances are site-scoped, so a bound
|
||||||
|
// connection cannot leave its site without orphaning the binding.
|
||||||
|
var blockingInstances = await repo.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
||||||
|
if (blockingInstances.Count > 0)
|
||||||
|
{
|
||||||
|
var names = string.Join(", ", blockingInstances
|
||||||
|
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(i => $"'{i.UniqueName}' (ID {i.Id})"));
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): it is referenced by " +
|
||||||
|
$"{blockingInstances.Count} instance binding(s) on its current site. Rebind or delete the " +
|
||||||
|
$"following instance(s) first: {names}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 4 (name-based native-alarm-source references): templates reference
|
||||||
|
// a connection by Name (TemplateNativeAlarmSource.ConnectionName) and
|
||||||
|
// instances may override that name
|
||||||
|
// (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a
|
||||||
|
// connection changes which physical connection that name resolves to on
|
||||||
|
// the source site, so any such reference to the moving name is a blocker.
|
||||||
|
// Detect and report only — never auto-rewrite.
|
||||||
|
var referenceBlockers = new List<string>();
|
||||||
|
|
||||||
|
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||||
|
var templates = await templateRepo.GetAllTemplatesAsync();
|
||||||
|
foreach (var template in templates)
|
||||||
|
{
|
||||||
|
foreach (var source in template.NativeAlarmSources)
|
||||||
|
{
|
||||||
|
if (string.Equals(source.ConnectionName, conn.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
referenceBlockers.Add(
|
||||||
|
$"template '{template.Name}' native-alarm-source '{source.Name}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance-level overrides on the SOURCE site that name this connection
|
||||||
|
// would orphan once the connection leaves the site.
|
||||||
|
var sourceInstances = await repo.GetInstancesBySiteIdAsync(sourceSiteId);
|
||||||
|
foreach (var instance in sourceInstances)
|
||||||
|
{
|
||||||
|
foreach (var ovr in instance.NativeAlarmSourceOverrides)
|
||||||
|
{
|
||||||
|
if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
referenceBlockers.Add(
|
||||||
|
$"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (referenceBlockers.Count > 0)
|
||||||
|
{
|
||||||
|
var details = string.Join(", ", referenceBlockers);
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): its name is referenced by " +
|
||||||
|
$"native-alarm-source binding(s) that would be orphaned or made ambiguous by the move. " +
|
||||||
|
$"Resolve the following reference(s) first: {details}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// All guards passed: persist the site change and audit.
|
||||||
|
conn.SiteId = cmd.TargetSiteId;
|
||||||
|
await repo.UpdateDataConnectionAsync(conn);
|
||||||
|
await repo.SaveChangesAsync();
|
||||||
|
await AuditAsync(sp, user, "Move", "DataConnection", conn.Id.ToString(), conn.Name, conn);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// External System handlers
|
// External System handlers
|
||||||
|
|||||||
@@ -2072,4 +2072,145 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
Assert.Contains("Deployer", response.Message);
|
Assert.Contains("Deployer", response.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// MoveDataConnectionCommand (M9 / T24a) — guarded cross-site move
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MoveDataConnection_WithViewerRole_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Viewer");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("Designer", response.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MoveDataConnection_HappyPath_MovesSiteIdAndAudits()
|
||||||
|
{
|
||||||
|
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 siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
|
||||||
|
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
|
||||||
|
// No name collision at the target site.
|
||||||
|
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Commons.Entities.Sites.DataConnection>());
|
||||||
|
// 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>())
|
||||||
|
.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");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||||
|
// SiteId is mutated to the target.
|
||||||
|
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>());
|
||||||
|
_auditService.Received(1).LogAsync(
|
||||||
|
"testuser", "Move", "DataConnection", "5", "Opc1",
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MoveDataConnection_TargetSiteMissing_ReturnsError()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
siteRepo.GetSiteByIdAsync(99, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((Commons.Entities.Sites.Site?)null);
|
||||||
|
_services.AddScoped(_ => siteRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope(new MoveDataConnectionCommand(5, 99), "Designer");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("99", response.Error);
|
||||||
|
// SiteId unchanged; no write occurred.
|
||||||
|
Assert.Equal(1, conn.SiteId);
|
||||||
|
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
|
||||||
|
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MoveDataConnection_NameCollisionAtTarget_ReturnsError()
|
||||||
|
{
|
||||||
|
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 collidingConn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 2) { Id = 9 };
|
||||||
|
|
||||||
|
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> { collidingConn });
|
||||||
|
_services.AddScoped(_ => siteRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("Opc1", response.Error);
|
||||||
|
Assert.Equal(1, conn.SiteId);
|
||||||
|
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
|
||||||
|
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MoveDataConnection_BlockedByBinding_ReturnsErrorNamingInstances()
|
||||||
|
{
|
||||||
|
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 blockingInstance = new Instance("Tank01") { Id = 7, SiteId = 1 };
|
||||||
|
|
||||||
|
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>());
|
||||||
|
// A binding references the connection -> move must be rejected.
|
||||||
|
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Instance> { blockingInstance });
|
||||||
|
_services.AddScoped(_ => siteRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
// Error names the blocking instance.
|
||||||
|
Assert.Contains("Tank01", response.Error);
|
||||||
|
// SiteId unchanged; no write occurred.
|
||||||
|
Assert.Equal(1, conn.SiteId);
|
||||||
|
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
|
||||||
|
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user