refactor: simplify data connections from many-to-many site assignment to direct site ownership

Replace SiteDataConnectionAssignment join table with a direct SiteId FK on DataConnection,
simplifying the data model, repositories, UI, CLI, and deployment service.
This commit is contained in:
Joseph Doherty
2026-03-21 21:07:10 -04:00
parent cd6efeea90
commit 970d0a5cb3
25 changed files with 1543 additions and 490 deletions

View File

@@ -15,8 +15,6 @@ public static class DataConnectionCommands
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAssign(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUnassign(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
@@ -60,49 +58,41 @@ public static class DataConnectionCommands
return cmd;
}
private static Command BuildUnassign(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--assignment-id") { Description = "Assignment ID", Required = true };
var cmd = new Command("unassign") { Description = "Unassign a data connection from a site" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new UnassignDataConnectionFromSiteCommand(id));
});
return cmd;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all data connections" };
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
var cmd = new Command("list") { Description = "List data connections" };
cmd.Add(siteIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand());
result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand(siteId));
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
var configOption = new Option<string?>("--configuration") { Description = "Connection configuration JSON" };
var cmd = new Command("create") { Description = "Create a new data connection" };
cmd.Add(siteIdOption);
cmd.Add(nameOption);
cmd.Add(protocolOption);
cmd.Add(configOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateDataConnectionCommand(name, protocol, config));
new CreateDataConnectionCommand(siteId, name, protocol, config));
});
return cmd;
}
@@ -120,23 +110,4 @@ public static class DataConnectionCommands
});
return cmd;
}
private static Command BuildAssign(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var connectionIdOption = new Option<int>("--connection-id") { Description = "Data connection ID", Required = true };
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var cmd = new Command("assign") { Description = "Assign a data connection to a site" };
cmd.Add(connectionIdOption);
cmd.Add(siteIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var connectionId = result.GetValue(connectionIdOption);
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AssignDataConnectionToSiteCommand(connectionId, siteId));
});
return cmd;
}
}

View File

@@ -603,22 +603,27 @@ scadalink --url <url> data-connection get --id <int>
#### `data-connection list`
List all configured data connections.
List data connections, optionally filtered by site.
```sh
scadalink --url <url> data-connection list
```
#### `data-connection create`
Create a new data connection definition.
```sh
scadalink --url <url> data-connection create --name <string> --protocol <string> [--configuration <json>]
scadalink --url <url> data-connection list [--site-id <int>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site-id` | no | Filter by site ID |
#### `data-connection create`
Create a new data connection belonging to a specific site.
```sh
scadalink --url <url> data-connection create --site-id <int> --name <string> --protocol <string> [--configuration <json>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site-id` | yes | Site ID the connection belongs to |
| `--name` | yes | Connection name |
| `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) |
| `--configuration` | no | Protocol-specific configuration as a JSON string |
@@ -650,32 +655,6 @@ scadalink --url <url> data-connection delete --id <int>
|--------|----------|-------------|
| `--id` | yes | Data connection ID |
#### `data-connection assign`
Assign a data connection to a site.
```sh
scadalink --url <url> data-connection assign --connection-id <int> --site-id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--connection-id` | yes | Data connection ID |
| `--site-id` | yes | Site ID |
#### `data-connection unassign`
Remove a data connection assignment from a site.
```sh
scadalink --url <url> data-connection unassign --connection-id <int> --site-id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--connection-id` | yes | Data connection ID |
| `--site-id` | yes | Site ID |
---
### `external-system` — Manage external HTTP systems

View File

@@ -21,6 +21,26 @@
{
<div class="card mb-3">
<div class="card-body">
@if (Id.HasValue)
{
<div class="mb-2">
<label class="form-label small">Site</label>
<input type="text" class="form-control form-control-sm" value="@(_siteName)" disabled />
</div>
}
else
{
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_formSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
</div>
}
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
@@ -57,6 +77,9 @@
private bool _loading = true;
private DataConnection? _editingConnection;
private List<Site> _sites = new();
private int _formSiteId;
private string _siteName = string.Empty;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
@@ -64,6 +87,8 @@
protected override async Task OnInitializedAsync()
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
if (Id.HasValue)
{
try
@@ -71,6 +96,8 @@
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
{
_formSiteId = _editingConnection.SiteId;
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_formName = _editingConnection.Name;
_formProtocol = _editingConnection.Protocol;
_formConfiguration = _editingConnection.Configuration;
@@ -87,6 +114,7 @@
private async Task SaveConnection()
{
_formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
@@ -101,7 +129,7 @@
}
else
{
var conn = new DataConnection(_formName.Trim(), _formProtocol)
var conn = new DataConnection(_formName.Trim(), _formProtocol, _formSiteId)
{
Configuration = _formConfiguration?.Trim()
};

View File

@@ -25,58 +25,14 @@
}
else
{
@* Assignment form *@
@if (_showAssignForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Assign Connection to Site</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Connection</label>
<select class="form-select form-select-sm" @bind="_assignConnectionId">
<option value="0">Select connection...</option>
@foreach (var conn in _connections)
{
<option value="@conn.Id">@conn.Name (@conn.Protocol)</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_assignSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveAssignment">Assign</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAssignForm">Cancel</button>
</div>
</div>
@if (_assignError != null)
{
<div class="text-danger small mt-1">@_assignError</div>
}
</div>
</div>
}
<div class="mb-2">
<button class="btn btn-outline-info btn-sm" @onclick="ShowAssignForm">Assign to Site</button>
</div>
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Protocol</th>
<th>Site</th>
<th>Configuration</th>
<th>Assigned Sites</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
@@ -93,29 +49,8 @@
<td>@conn.Id</td>
<td>@conn.Name</td>
<td><span class="badge bg-secondary">@conn.Protocol</span></td>
<td>@(_siteLookup.GetValueOrDefault(conn.SiteId)?.Name ?? $"Site {conn.SiteId}")</td>
<td class="text-muted small text-truncate" style="max-width: 300px;">@(conn.Configuration ?? "—")</td>
<td>
@{
var assignedSites = _connectionSites.GetValueOrDefault(conn.Id);
}
@if (assignedSites != null && assignedSites.Count > 0)
{
@foreach (var assignment in assignedSites)
{
var siteName = _sites.FirstOrDefault(s => s.Id == assignment.SiteId)?.Name ?? $"Site {assignment.SiteId}";
<span class="badge bg-info text-dark me-1">
@siteName
<button type="button" class="btn-close btn-close-white ms-1"
style="font-size: 0.5rem;"
@onclick="() => RemoveAssignment(assignment)"></button>
</span>
}
}
else
{
<span class="text-muted small">None</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit</button>
@@ -131,16 +66,10 @@
@code {
private List<DataConnection> _connections = new();
private List<Site> _sites = new();
private Dictionary<int, List<SiteDataConnectionAssignment>> _connectionSites = new();
private Dictionary<int, Site> _siteLookup = new();
private bool _loading = true;
private string? _errorMessage;
private bool _showAssignForm;
private int _assignConnectionId;
private int _assignSiteId;
private string? _assignError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -155,24 +84,9 @@
_errorMessage = null;
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
var sites = await SiteRepository.GetAllSitesAsync();
_siteLookup = sites.ToDictionary(s => s.Id);
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
// Load site assignments for each connection
_connectionSites.Clear();
foreach (var site in _sites)
{
var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
foreach (var conn in siteConns)
{
if (!_connectionSites.ContainsKey(conn.Id))
_connectionSites[conn.Id] = new List<SiteDataConnectionAssignment>();
var assignment = await SiteRepository.GetSiteDataConnectionAssignmentAsync(site.Id, conn.Id);
if (assignment != null)
_connectionSites[conn.Id].Add(assignment);
}
}
}
catch (Exception ex)
{
@@ -199,58 +113,4 @@
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
private void ShowAssignForm()
{
_assignConnectionId = 0;
_assignSiteId = 0;
_assignError = null;
_showAssignForm = true;
}
private void CancelAssignForm()
{
_showAssignForm = false;
_assignError = null;
}
private async Task SaveAssignment()
{
_assignError = null;
if (_assignConnectionId == 0) { _assignError = "Select a connection."; return; }
if (_assignSiteId == 0) { _assignError = "Select a site."; return; }
try
{
var assignment = new SiteDataConnectionAssignment
{
SiteId = _assignSiteId,
DataConnectionId = _assignConnectionId
};
await SiteRepository.AddSiteDataConnectionAssignmentAsync(assignment);
await SiteRepository.SaveChangesAsync();
_showAssignForm = false;
_toast.ShowSuccess("Connection assigned to site.");
await LoadDataAsync();
}
catch (Exception ex)
{
_assignError = $"Assignment failed: {ex.Message}";
}
}
private async Task RemoveAssignment(SiteDataConnectionAssignment assignment)
{
try
{
await SiteRepository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id);
await SiteRepository.SaveChangesAsync();
_toast.ShowSuccess("Assignment removed.");
await LoadDataAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Remove failed: {ex.Message}");
}
}
}

View File

@@ -178,10 +178,9 @@
_deploying = true;
try
{
var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync();
var user = await GetCurrentUserAsync();
var result = await ArtifactDeploymentService.RetryForSiteAsync(
site.SiteIdentifier, command, user);
site.Id, site.SiteIdentifier, user);
if (result.IsSuccess)
_toast.ShowSuccess($"Artifacts deployed to '{site.Name}'.");
@@ -203,9 +202,8 @@
_deploying = true;
try
{
var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync();
var user = await GetCurrentUserAsync();
var result = await ArtifactDeploymentService.DeployToAllSitesAsync(command, user);
var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user);
if (result.IsSuccess)
{

View File

@@ -697,13 +697,8 @@
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
// Load data connections for this site
// Load data connections for this site (each connection now belongs to exactly one site)
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(inst.SiteId)).ToList();
if (_siteConnections.Count == 0)
{
// Also show unassigned connections (they may not be assigned to a site yet)
_siteConnections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
}
// Load existing bindings
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(inst.Id);

View File

@@ -3,13 +3,15 @@ namespace ScadaLink.Commons.Entities.Sites;
public class DataConnection
{
public int Id { get; set; }
public int SiteId { get; set; }
public string Name { get; set; }
public string Protocol { get; set; }
public string? Configuration { get; set; }
public DataConnection(string name, string protocol)
public DataConnection(string name, string protocol, int siteId)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Protocol = protocol ?? throw new ArgumentNullException(nameof(protocol));
SiteId = siteId;
}
}

View File

@@ -1,8 +0,0 @@
namespace ScadaLink.Commons.Entities.Sites;
public class SiteDataConnectionAssignment
{
public int Id { get; set; }
public int SiteId { get; set; }
public int DataConnectionId { get; set; }
}

View File

@@ -10,7 +10,7 @@ public interface ICentralUiRepository
{
Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SiteDataConnectionAssignment>> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(int? siteId = null, int? templateId = null, string? searchTerm = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default);

View File

@@ -24,11 +24,6 @@ public interface ISiteRepository
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
// Site-Connection Assignments
Task<SiteDataConnectionAssignment?> GetSiteDataConnectionAssignmentAsync(int siteId, int dataConnectionId, CancellationToken cancellationToken = default);
Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default);
Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default);
// Instances (for deletion constraint checks)
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);

View File

@@ -1,9 +1,7 @@
namespace ScadaLink.Commons.Messages.Management;
public record ListDataConnectionsCommand;
public record ListDataConnectionsCommand(int? SiteId = null);
public record GetDataConnectionCommand(int DataConnectionId);
public record CreateDataConnectionCommand(string Name, string Protocol, string? Configuration);
public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? Configuration);
public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? Configuration);
public record DeleteDataConnectionCommand(int DataConnectionId);
public record AssignDataConnectionToSiteCommand(int DataConnectionId, int SiteId);
public record UnassignDataConnectionFromSiteCommand(int AssignmentId);

View File

@@ -46,26 +46,11 @@ public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnecti
builder.Property(d => d.Configuration)
.HasMaxLength(4000);
builder.HasIndex(d => d.Name).IsUnique();
}
}
public class SiteDataConnectionAssignmentConfiguration : IEntityTypeConfiguration<SiteDataConnectionAssignment>
{
public void Configure(EntityTypeBuilder<SiteDataConnectionAssignment> builder)
{
builder.HasKey(a => a.Id);
builder.HasOne<Site>()
.WithMany()
.HasForeignKey(a => a.SiteId)
.OnDelete(DeleteBehavior.Cascade);
.HasForeignKey(d => d.SiteId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<DataConnection>()
.WithMany()
.HasForeignKey(a => a.DataConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => new { a.SiteId, a.DataConnectionId }).IsUnique();
builder.HasIndex(d => new { d.SiteId, d.Name }).IsUnique();
}
}

View File

@@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddSiteIdToDataConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Step 1: Drop old unique index on Name (allows duplicate names across sites)
migrationBuilder.DropIndex(
name: "IX_DataConnections_Name",
table: "DataConnections");
// Step 2: Add nullable SiteId column
migrationBuilder.AddColumn<int>(
name: "SiteId",
table: "DataConnections",
type: "int",
nullable: true);
// Step 3: Migrate data from SiteDataConnectionAssignments
migrationBuilder.Sql(@"
-- Phase A: Assign the first site to each existing DataConnection
UPDATE dc
SET dc.SiteId = a.SiteId
FROM DataConnections dc
INNER JOIN (
SELECT DataConnectionId, MIN(SiteId) AS SiteId
FROM SiteDataConnectionAssignments
GROUP BY DataConnectionId
) a ON dc.Id = a.DataConnectionId
WHERE dc.SiteId IS NULL;
-- Phase B: For connections assigned to additional sites, create copies
-- and update InstanceConnectionBindings to point to the new copy
DECLARE @AssignSiteId INT, @AssignConnId INT, @NewConnId INT;
DECLARE @OrigName NVARCHAR(200), @OrigProtocol NVARCHAR(50), @OrigConfig NVARCHAR(4000);
DECLARE assignment_cursor CURSOR FOR
SELECT a.SiteId, a.DataConnectionId
FROM SiteDataConnectionAssignments a
INNER JOIN DataConnections dc ON a.DataConnectionId = dc.Id
WHERE dc.SiteId <> a.SiteId;
OPEN assignment_cursor;
FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId;
WHILE @@FETCH_STATUS = 0
BEGIN
SELECT @OrigName = Name, @OrigProtocol = Protocol, @OrigConfig = Configuration
FROM DataConnections WHERE Id = @AssignConnId;
INSERT INTO DataConnections (SiteId, Name, Protocol, Configuration)
VALUES (@AssignSiteId, @OrigName, @OrigProtocol, @OrigConfig);
SET @NewConnId = SCOPE_IDENTITY();
-- Update bindings for instances on this site to point to the new connection
UPDATE icb
SET icb.DataConnectionId = @NewConnId
FROM InstanceConnectionBindings icb
INNER JOIN Instances i ON icb.InstanceId = i.Id
WHERE icb.DataConnectionId = @AssignConnId
AND i.SiteId = @AssignSiteId;
FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId;
END
CLOSE assignment_cursor;
DEALLOCATE assignment_cursor;
-- Phase C: Handle any DataConnections not assigned to any site
-- (assign to the first site as a fallback)
UPDATE dc
SET dc.SiteId = (SELECT TOP 1 Id FROM Sites ORDER BY Id)
FROM DataConnections dc
WHERE dc.SiteId IS NULL;
");
// Step 4: Make SiteId non-nullable
migrationBuilder.AlterColumn<int>(
name: "SiteId",
table: "DataConnections",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
// Step 5: Add composite unique index and FK
migrationBuilder.CreateIndex(
name: "IX_DataConnections_SiteId_Name",
table: "DataConnections",
columns: new[] { "SiteId", "Name" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_DataConnections_Sites_SiteId",
table: "DataConnections",
column: "SiteId",
principalTable: "Sites",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
// Step 6: Drop SiteDataConnectionAssignments table
migrationBuilder.DropTable(
name: "SiteDataConnectionAssignments");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Recreate SiteDataConnectionAssignments table
migrationBuilder.CreateTable(
name: "SiteDataConnectionAssignments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DataConnectionId = table.Column<int>(type: "int", nullable: false),
SiteId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SiteDataConnectionAssignments", x => x.Id);
table.ForeignKey(
name: "FK_SiteDataConnectionAssignments_DataConnections_DataConnectionId",
column: x => x.DataConnectionId,
principalTable: "DataConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SiteDataConnectionAssignments_Sites_SiteId",
column: x => x.SiteId,
principalTable: "Sites",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_SiteDataConnectionAssignments_DataConnectionId",
table: "SiteDataConnectionAssignments",
column: "DataConnectionId");
migrationBuilder.CreateIndex(
name: "IX_SiteDataConnectionAssignments_SiteId_DataConnectionId",
table: "SiteDataConnectionAssignments",
columns: new[] { "SiteId", "DataConnectionId" },
unique: true);
// Migrate data back
migrationBuilder.Sql(@"
INSERT INTO SiteDataConnectionAssignments (SiteId, DataConnectionId)
SELECT SiteId, Id FROM DataConnections;
");
// Remove FK and composite index
migrationBuilder.DropForeignKey(
name: "FK_DataConnections_Sites_SiteId",
table: "DataConnections");
migrationBuilder.DropIndex(
name: "IX_DataConnections_SiteId_Name",
table: "DataConnections");
// Restore unique index on Name
migrationBuilder.CreateIndex(
name: "IX_DataConnections_Name",
table: "DataConnections",
column: "Name",
unique: true);
// Drop SiteId column
migrationBuilder.DropColumn(
name: "SiteId",
table: "DataConnections");
}
}
}

View File

@@ -766,9 +766,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("SiteId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("SiteId", "Name")
.IsUnique();
b.ToTable("DataConnections");
@@ -821,30 +824,6 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("Sites");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DataConnectionId")
.HasColumnType("int");
b.Property<int>("SiteId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("DataConnectionId");
b.HasIndex("SiteId", "DataConnectionId")
.IsUnique();
b.ToTable("SiteDataConnectionAssignments");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b =>
{
b.Property<int>("Id")
@@ -1153,18 +1132,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsRequired();
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b =>
modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b =>
{
b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null)
.WithMany()
.HasForeignKey("DataConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null)
.WithMany()
.HasForeignKey("SiteId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});

View File

@@ -27,17 +27,18 @@ public class CentralUiRepository : ICentralUiRepository
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.SiteDataConnectionAssignments
return await _context.DataConnections
.AsNoTracking()
.Where(a => a.SiteId == siteId)
.Join(_context.DataConnections, a => a.DataConnectionId, d => d.Id, (_, d) => d)
.Where(d => d.SiteId == siteId)
.OrderBy(d => d.Name)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<SiteDataConnectionAssignment>> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{
return await _context.SiteDataConnectionAssignments
return await _context.DataConnections
.AsNoTracking()
.OrderBy(d => d.Name)
.ToListAsync(cancellationToken);
}

View File

@@ -76,13 +76,8 @@ public class SiteRepository : ISiteRepository
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
var connectionIds = await _dbContext.SiteDataConnectionAssignments
.Where(a => a.SiteId == siteId)
.Select(a => a.DataConnectionId)
.ToListAsync(cancellationToken);
return await _dbContext.DataConnections
.Where(c => connectionIds.Contains(c.Id))
.Where(c => c.SiteId == siteId)
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
}
@@ -107,43 +102,13 @@ public class SiteRepository : ISiteRepository
}
else
{
var stub = new DataConnection("stub", "stub") { Id = id };
var stub = new DataConnection("stub", "stub", 0) { Id = id };
_dbContext.DataConnections.Attach(stub);
_dbContext.DataConnections.Remove(stub);
}
return Task.CompletedTask;
}
// --- Site-Connection Assignments ---
public async Task<SiteDataConnectionAssignment?> GetSiteDataConnectionAssignmentAsync(
int siteId, int dataConnectionId, CancellationToken cancellationToken = default)
{
return await _dbContext.SiteDataConnectionAssignments
.FirstOrDefaultAsync(a => a.SiteId == siteId && a.DataConnectionId == dataConnectionId, cancellationToken);
}
public async Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default)
{
await _dbContext.SiteDataConnectionAssignments.AddAsync(assignment, cancellationToken);
}
public Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default)
{
var entity = _dbContext.SiteDataConnectionAssignments.Local.FirstOrDefault(a => a.Id == id);
if (entity != null)
{
_dbContext.SiteDataConnectionAssignments.Remove(entity);
}
else
{
var stub = new SiteDataConnectionAssignment { Id = id };
_dbContext.SiteDataConnectionAssignments.Attach(stub);
_dbContext.SiteDataConnectionAssignments.Remove(stub);
}
return Task.CompletedTask;
}
// --- Instances (for deletion constraint checks) ---
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)

View File

@@ -35,7 +35,6 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
// Sites
public DbSet<Site> Sites => Set<Site>();
public DbSet<DataConnection> DataConnections => Set<DataConnection>();
public DbSet<SiteDataConnectionAssignment> SiteDataConnectionAssignments => Set<SiteDataConnectionAssignment>();
// Deployment
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();

View File

@@ -55,16 +55,18 @@ public class ArtifactDeploymentService
}
/// <summary>
/// Collects all artifact types from repositories and builds a <see cref="DeployArtifactsCommand"/>.
/// Collects all artifact types from repositories and builds a <see cref="DeployArtifactsCommand"/>
/// scoped to a specific site's data connections.
/// </summary>
public async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync(
int siteId,
CancellationToken cancellationToken = default)
{
var sharedScripts = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var externalSystems = await _externalSystemRepo.GetAllExternalSystemsAsync(cancellationToken);
var dbConnections = await _externalSystemRepo.GetAllDatabaseConnectionsAsync(cancellationToken);
var notificationLists = await _notificationRepo.GetAllNotificationListsAsync(cancellationToken);
var dataConnections = await _siteRepo.GetAllDataConnectionsAsync(cancellationToken);
var dataConnections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken);
var smtpConfigurations = await _notificationRepo.GetAllSmtpConfigurationsAsync(cancellationToken);
// Map shared scripts
@@ -120,10 +122,10 @@ public class ArtifactDeploymentService
}
/// <summary>
/// Deploys artifacts to all sites. Returns per-site result matrix.
/// Deploys artifacts to all sites. Builds a per-site command with that site's data connections.
/// Returns per-site result matrix.
/// </summary>
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
DeployArtifactsCommand command,
string user,
CancellationToken cancellationToken = default)
{
@@ -131,9 +133,17 @@ public class ArtifactDeploymentService
if (sites.Count == 0)
return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
var deploymentId = Guid.NewGuid().ToString("N");
var perSiteResults = new Dictionary<string, SiteArtifactResult>();
// Deploy to each site with per-site timeout
// Build per-site commands sequentially (DbContext is not thread-safe)
var siteCommands = new Dictionary<int, DeployArtifactsCommand>();
foreach (var site in sites)
{
siteCommands[site.Id] = await BuildDeployArtifactsCommandAsync(site.Id, cancellationToken);
}
// Deploy to each site in parallel with per-site timeout
var tasks = sites.Select(async site =>
{
try
@@ -141,6 +151,8 @@ public class ArtifactDeploymentService
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite);
var command = siteCommands[site.Id];
_logger.LogInformation(
"Deploying artifacts to site {SiteId} ({SiteName}), deploymentId={DeploymentId}",
site.SiteIdentifier, site.Name, command.DeploymentId);
@@ -188,13 +200,13 @@ public class ArtifactDeploymentService
await _deploymentRepo.SaveChangesAsync(cancellationToken);
var summary = new ArtifactDeploymentSummary(
command.DeploymentId,
deploymentId,
results.ToList(),
results.Count(r => r.Success),
results.Count(r => !r.Success));
await _auditService.LogAsync(user, "DeployArtifacts", "SystemArtifact",
command.DeploymentId, "Artifacts",
deploymentId, "Artifacts",
new { summary.SuccessCount, summary.FailureCount },
cancellationToken);
@@ -205,8 +217,8 @@ public class ArtifactDeploymentService
/// WP-7: Retry artifact deployment to a specific site that previously failed.
/// </summary>
public async Task<Result<SiteArtifactResult>> RetryForSiteAsync(
string siteId,
DeployArtifactsCommand command,
int siteDbId,
string siteIdentifier,
string user,
CancellationToken cancellationToken = default)
{
@@ -215,12 +227,13 @@ public class ArtifactDeploymentService
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite);
var response = await _communicationService.DeployArtifactsAsync(siteId, command, cts.Token);
var command = await BuildDeployArtifactsCommandAsync(siteDbId, cts.Token);
var response = await _communicationService.DeployArtifactsAsync(siteIdentifier, command, cts.Token);
var result = new SiteArtifactResult(siteId, siteId, response.Success, response.ErrorMessage);
var result = new SiteArtifactResult(siteIdentifier, siteIdentifier, response.Success, response.ErrorMessage);
await _auditService.LogAsync(user, "RetryArtifactDeployment", "SystemArtifact",
command.DeploymentId, siteId, new { response.Success }, cancellationToken);
command.DeploymentId, siteIdentifier, new { response.Success }, cancellationToken);
return response.Success
? Result<SiteArtifactResult>.Success(result)
@@ -228,7 +241,7 @@ public class ArtifactDeploymentService
}
catch (Exception ex)
{
return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteId}: {ex.Message}");
return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}");
}
}
}

View File

@@ -103,8 +103,6 @@ public class ManagementActor : ReceiveActor
or UpdateSmtpConfigCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand
or AssignDataConnectionToSiteCommand
or UnassignDataConnectionFromSiteCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
@@ -176,13 +174,11 @@ public class ManagementActor : ReceiveActor
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
// Data Connections
ListDataConnectionsCommand => await HandleListDataConnections(sp),
ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd),
GetDataConnectionCommand cmd => await HandleGetDataConnection(sp, cmd),
CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
AssignDataConnectionToSiteCommand cmd => await HandleAssignDataConnectionToSite(sp, cmd, user.Username),
UnassignDataConnectionFromSiteCommand cmd => await HandleUnassignDataConnectionFromSite(sp, cmd, user.Username),
// External Systems
ListExternalSystemsCommand => await HandleListExternalSystems(sp),
@@ -676,9 +672,11 @@ public class ManagementActor : ReceiveActor
// Data Connection handlers
// ========================================================================
private static async Task<object?> HandleListDataConnections(IServiceProvider sp)
private static async Task<object?> HandleListDataConnections(IServiceProvider sp, ListDataConnectionsCommand cmd)
{
var repo = sp.GetRequiredService<ISiteRepository>();
if (cmd.SiteId.HasValue)
return await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value);
return await repo.GetAllDataConnectionsAsync();
}
@@ -691,7 +689,7 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISiteRepository>();
var conn = new DataConnection(cmd.Name, cmd.Protocol) { Configuration = cmd.Configuration };
var conn = new DataConnection(cmd.Name, cmd.Protocol, cmd.SiteId) { Configuration = cmd.Configuration };
await repo.AddDataConnectionAsync(conn);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn);
@@ -721,28 +719,6 @@ public class ManagementActor : ReceiveActor
return true;
}
private static async Task<object?> HandleAssignDataConnectionToSite(IServiceProvider sp, AssignDataConnectionToSiteCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISiteRepository>();
var assignment = new SiteDataConnectionAssignment
{
SiteId = cmd.SiteId,
DataConnectionId = cmd.DataConnectionId
};
await repo.AddSiteDataConnectionAssignmentAsync(assignment);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Assign", "DataConnection", cmd.DataConnectionId.ToString(), $"Site:{cmd.SiteId}", assignment);
return assignment;
}
private static async Task<object?> HandleUnassignDataConnectionFromSite(IServiceProvider sp, UnassignDataConnectionFromSiteCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISiteRepository>();
await repo.DeleteSiteDataConnectionAssignmentAsync(cmd.AssignmentId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Unassign", "DataConnection", cmd.AssignmentId.ToString(), cmd.AssignmentId.ToString(), null);
return true;
}
// ========================================================================
// External System handlers
@@ -1011,8 +987,7 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, string user)
{
var svc = sp.GetRequiredService<ArtifactDeploymentService>();
var command = await svc.BuildDeployArtifactsCommandAsync();
var result = await svc.DeployToAllSitesAsync(command, user);
var result = await svc.DeployToAllSitesAsync(user);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);

View File

@@ -8,9 +8,7 @@ namespace ScadaLink.TemplateEngine.Services;
/// <summary>
/// Site and data connection management.
/// - Site CRUD (name, identifier, description)
/// - Data connection CRUD (name, protocol, config)
/// - Assign connections to sites
/// - Connection names not standardized across sites
/// - Data connection CRUD (name, protocol, config) — each connection belongs to exactly one site
/// - Audit logging
/// </summary>
public class SiteService
@@ -98,7 +96,7 @@ public class SiteService
// --- Data Connection CRUD ---
public async Task<Result<DataConnection>> CreateDataConnectionAsync(
string name, string protocol, string? configuration, string user,
int siteId, string name, string protocol, string? configuration, string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
@@ -106,7 +104,7 @@ public class SiteService
if (string.IsNullOrWhiteSpace(protocol))
return Result<DataConnection>.Failure("Protocol is required.");
var connection = new DataConnection(name, protocol) { Configuration = configuration };
var connection = new DataConnection(name, protocol, siteId) { Configuration = configuration };
await _repository.AddDataConnectionAsync(connection, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -151,56 +149,4 @@ public class SiteService
return Result<bool>.Success(true);
}
// --- Site-Connection Assignment ---
public async Task<Result<SiteDataConnectionAssignment>> AssignConnectionToSiteAsync(
int siteId, int dataConnectionId, string user,
CancellationToken cancellationToken = default)
{
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
if (site == null)
return Result<SiteDataConnectionAssignment>.Failure($"Site with ID {siteId} not found.");
var connection = await _repository.GetDataConnectionByIdAsync(dataConnectionId, cancellationToken);
if (connection == null)
return Result<SiteDataConnectionAssignment>.Failure($"Data connection with ID {dataConnectionId} not found.");
// Check if assignment already exists
var existing = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken);
if (existing != null)
return Result<SiteDataConnectionAssignment>.Failure(
$"Data connection '{connection.Name}' is already assigned to site '{site.Name}'.");
var assignment = new SiteDataConnectionAssignment
{
SiteId = siteId,
DataConnectionId = dataConnectionId
};
await _repository.AddSiteDataConnectionAssignmentAsync(assignment, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "AssignConnection", "SiteDataConnectionAssignment",
assignment.Id.ToString(), $"{site.Name}/{connection.Name}", assignment, cancellationToken);
return Result<SiteDataConnectionAssignment>.Success(assignment);
}
public async Task<Result<bool>> RemoveConnectionFromSiteAsync(
int siteId, int dataConnectionId, string user,
CancellationToken cancellationToken = default)
{
var assignment = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken);
if (assignment == null)
return Result<bool>.Failure("Assignment not found.");
await _repository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "RemoveConnection", "SiteDataConnectionAssignment",
assignment.Id.ToString(), $"Site:{siteId}/Conn:{dataConnectionId}", null, cancellationToken);
return Result<bool>.Success(true);
}
}

View File

@@ -45,7 +45,6 @@ public class DbContextTests : IDisposable
Assert.NotNull(_context.Areas);
Assert.NotNull(_context.Sites);
Assert.NotNull(_context.DataConnections);
Assert.NotNull(_context.SiteDataConnectionAssignments);
Assert.NotNull(_context.DeploymentRecords);
Assert.NotNull(_context.SystemArtifactDeploymentRecords);
Assert.NotNull(_context.ExternalSystemDefinitions);
@@ -133,9 +132,11 @@ public class DbContextTests : IDisposable
{
var site = new Site("Site1", "SITE-001");
var template = new Template("Template1");
var dataConn = new DataConnection("OpcConn", "OpcUa");
_context.Sites.Add(site);
_context.Templates.Add(template);
_context.SaveChanges();
var dataConn = new DataConnection("OpcConn", "OpcUa", site.Id);
_context.DataConnections.Add(dataConn);
_context.SaveChanges();
@@ -300,19 +301,18 @@ public class DbContextTests : IDisposable
}
[Fact]
public void SiteDataConnectionAssignment_CreatesBothForeignKeys()
public void DataConnection_BelongsToSite()
{
var site = new Site("Site1", "SITE-001");
var conn = new DataConnection("OpcConn", "OpcUa");
_context.Sites.Add(site);
_context.SaveChanges();
var conn = new DataConnection("OpcConn", "OpcUa", site.Id);
_context.DataConnections.Add(conn);
_context.SaveChanges();
var assignment = new SiteDataConnectionAssignment { SiteId = site.Id, DataConnectionId = conn.Id };
_context.SiteDataConnectionAssignments.Add(assignment);
_context.SaveChanges();
Assert.Single(_context.SiteDataConnectionAssignments.ToList());
var loaded = _context.DataConnections.Single(c => c.Name == "OpcConn");
Assert.Equal(site.Id, loaded.SiteId);
}
[Fact]

View File

@@ -37,9 +37,8 @@ public class ArtifactDeploymentServiceTests
_siteRepo.GetAllSitesAsync().Returns(new List<Site>());
var service = CreateService();
var command = CreateCommand();
var result = await service.DeployToAllSitesAsync(command, "admin");
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsFailure);
Assert.Contains("No sites", result.Error);

View File

@@ -200,7 +200,7 @@ public class FlatteningServiceTests
var connections = new Dictionary<int, DataConnection>
{
[100] = new("OPC-Server1", "OpcUa") { Id = 100, Configuration = "opc.tcp://localhost:4840" }
[100] = new("OPC-Server1", "OpcUa", 1) { Id = 100, Configuration = "opc.tcp://localhost:4840" }
};
var result = _sut.Flatten(

View File

@@ -93,44 +93,12 @@ public class SiteServiceTests
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.CreateDataConnectionAsync("OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", "admin");
var result = await _sut.CreateDataConnectionAsync(1, "OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("OPC-Server1", result.Value.Name);
Assert.Equal("OpcUa", result.Value.Protocol);
}
[Fact]
public async Task AssignConnectionToSite_AlreadyAssigned_ReturnsFailure()
{
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SiteDataConnectionAssignment { Id = 1, SiteId = 1, DataConnectionId = 100 });
var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already assigned", result.Error);
}
[Fact]
public async Task AssignConnectionToSite_Valid_Success()
{
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
.ReturnsAsync((SiteDataConnectionAssignment?)null);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.SiteId);
}
[Fact]