Files
scadalink-design/docs/plans/2026-05-12-opcua-config-model.md
Joseph Doherty a9c4c2c655 docs(plans): implementation plan for OPC UA config model refactor
14 bite-sized tasks (TDD pattern) covering:
- Commons foundation: POCOs, serializer, validator
- Runtime adoption: OpcUaDataConnection + DeploymentManagerActor swap
- UI build: <OpcUaEndpointEditor> + DataConnectionForm rewrite
- Verification: build/test green + Docker browser smoke + push

Tasks #45-#58 created with blocking dependencies; companion
.tasks.json sidecar persists the plan for executing-plans skill.
2026-05-12 00:33:51 -04:00

63 KiB
Raw Blame History

OPC UA Endpoint Config Model & Form Refactor — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Extract OPC UA endpoint configuration from free-form JSON strings into a strongly-typed OpcUaEndpointConfig POCO with a validator and serializer in Commons; consume the model from both the site-side runtime (OpcUaDataConnection) and the central UI (/admin/connections create/edit), rendering typed Bootstrap controls instead of a JSON textarea.

Architecture: New POCO/validator/serializer in ScadaLink.Commons are the single schema. Typed nested JSON (System.Text.Json, camelCase) is the storage shape; a legacy flat string-dict fallback in the serializer keeps existing rows readable. IDataConnection.ConnectAsync keeps its IDictionary<string,string> contract (protocol-agnostic), and OpcUaDataConnection uses OpcUaEndpointConfigSerializer.FromFlatDict internally to get the typed model. A shared <OpcUaEndpointEditor> Blazor component renders the form, used twice (primary + backup).

Tech Stack: C# 12 / .NET 10, System.Text.Json, Blazor Server, Bootstrap 5, xUnit + bUnit + NSubstitute.

Design doc: docs/plans/2026-05-12-opcua-config-model-design.md


Tasks at a glance

  • Task 1: Create POCOs (OpcUaEndpointConfig, OpcUaHeartbeatConfig, OpcUaSecurityMode) + add ConnectionConfig to ValidationCategory.
  • Task 2: TDD OpcUaEndpointConfigSerializer — write failing tests for Serialize/Deserialize/legacy/ToFlatDict/FromFlatDict.
  • Task 3: Implement OpcUaEndpointConfigSerializer until tests pass.
  • Task 4: TDD OpcUaEndpointConfigValidator — write failing tests for all rules.
  • Task 5: Implement OpcUaEndpointConfigValidator until tests pass.
  • Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict. Remove ParseInt/ParseBool helpers and the string-fishing ladder.
  • Task 7: Refactor DeploymentManagerActor.EnsureDclConnections to round-trip JSON → model → flat dict via the serializer (handles both new typed nested JSON and legacy flat dict).
  • Task 8: TDD <OpcUaEndpointEditor> Blazor component — bUnit tests for binding, heartbeat toggle, error rendering, legacy banner.
  • Task 9: Implement OpcUaEndpointEditor.razor until tests pass.
  • Task 10: TDD DataConnectionForm.razor refactor — bUnit tests for invalid-save / valid-save / legacy-load.
  • Task 11: Refactor DataConnectionForm.razor to use editor; remove Protocol dropdown; set entity Protocol = "OpcUa" on save.
  • Task 12: Full solution build + all test suites green.
  • Task 13: Docker deploy + browser smoke.
  • Task 14: Commit & push.

Task 1: Create POCOs and add ValidationCategory value

Files:

  • Create: src/ScadaLink.Commons/Types/DataConnections/OpcUaSecurityMode.cs
  • Create: src/ScadaLink.Commons/Types/DataConnections/OpcUaHeartbeatConfig.cs
  • Create: src/ScadaLink.Commons/Types/DataConnections/OpcUaEndpointConfig.cs
  • Modify: src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs (add ConnectionConfig enum value)

Step 1: Create OpcUaSecurityMode.cs

namespace ScadaLink.Commons.Types.DataConnections;

public enum OpcUaSecurityMode
{
    None,
    Sign,
    SignAndEncrypt
}

Step 2: Create OpcUaHeartbeatConfig.cs

namespace ScadaLink.Commons.Types.DataConnections;

public sealed class OpcUaHeartbeatConfig
{
    public string TagPath { get; set; } = "";
    public int MaxSilenceSeconds { get; set; } = 30;
}

Step 3: Create OpcUaEndpointConfig.cs

namespace ScadaLink.Commons.Types.DataConnections;

public sealed class OpcUaEndpointConfig
{
    // Connection
    public string EndpointUrl { get; set; } = "";
    public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
    public bool AutoAcceptUntrustedCerts { get; set; } = true;

    // Timing
    public int SessionTimeoutMs { get; set; } = 60000;
    public int OperationTimeoutMs { get; set; } = 15000;

    // Subscription
    public int PublishingIntervalMs { get; set; } = 1000;
    public int SamplingIntervalMs { get; set; } = 1000;
    public int QueueSize { get; set; } = 10;
    public int KeepAliveCount { get; set; } = 10;
    public int LifetimeCount { get; set; } = 30;
    public int MaxNotificationsPerPublish { get; set; } = 100;

    // Heartbeat (optional)
    public OpcUaHeartbeatConfig? Heartbeat { get; set; }
}

Step 4: Add ConnectionConfig to ValidationCategory

In src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs:50-65, append ConnectionConfig to the enum:

public enum ValidationCategory
{
    FlatteningFailure,
    NamingCollision,
    ScriptCompilation,
    AlarmTriggerReference,
    ScriptTriggerReference,
    ConnectionBinding,
    CallTargetNotFound,
    ParameterMismatch,
    ReturnTypeMismatch,
    TriggerOperandType,
    OnTriggerScriptNotFound,
    CrossCallViolation,
    MissingMetadata,
    ConnectionConfig
}

Step 5: Build to confirm everything compiles

Run: dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj Expected: build succeeded, 0 errors.

Step 6: Commit

git add src/ScadaLink.Commons/Types/DataConnections/ src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs
git commit -m "feat(commons): OpcUaEndpointConfig POCOs + ConnectionConfig ValidationCategory"

Task 2: TDD — OpcUaEndpointConfigSerializer tests (failing)

Files:

  • Create: tests/ScadaLink.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs

Step 1: Write the failing test file

using ScadaLink.Commons.Serialization;
using ScadaLink.Commons.Types.DataConnections;

namespace ScadaLink.Commons.Tests.Types.DataConnections;

public class OpcUaEndpointConfigSerializerTests
{
    [Fact]
    public void Serialize_TypedRoundtrip_PreservesAllFields()
    {
        var original = new OpcUaEndpointConfig
        {
            EndpointUrl = "opc.tcp://plant-a:4840",
            SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
            AutoAcceptUntrustedCerts = false,
            SessionTimeoutMs = 90000,
            OperationTimeoutMs = 20000,
            PublishingIntervalMs = 500,
            SamplingIntervalMs = 250,
            QueueSize = 50,
            KeepAliveCount = 5,
            LifetimeCount = 15,
            MaxNotificationsPerPublish = 200,
            Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Sensors.HB", MaxSilenceSeconds = 60 }
        };

        var json = OpcUaEndpointConfigSerializer.Serialize(original);
        var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);

        Assert.False(isLegacy);
        Assert.Equal(original.EndpointUrl, round.EndpointUrl);
        Assert.Equal(original.SecurityMode, round.SecurityMode);
        Assert.Equal(original.AutoAcceptUntrustedCerts, round.AutoAcceptUntrustedCerts);
        Assert.Equal(original.SessionTimeoutMs, round.SessionTimeoutMs);
        Assert.Equal(original.OperationTimeoutMs, round.OperationTimeoutMs);
        Assert.Equal(original.PublishingIntervalMs, round.PublishingIntervalMs);
        Assert.Equal(original.SamplingIntervalMs, round.SamplingIntervalMs);
        Assert.Equal(original.QueueSize, round.QueueSize);
        Assert.Equal(original.KeepAliveCount, round.KeepAliveCount);
        Assert.Equal(original.LifetimeCount, round.LifetimeCount);
        Assert.Equal(original.MaxNotificationsPerPublish, round.MaxNotificationsPerPublish);
        Assert.NotNull(round.Heartbeat);
        Assert.Equal("Sensors.HB", round.Heartbeat!.TagPath);
        Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds);
    }

    [Fact]
    public void Serialize_UsesCamelCase()
    {
        var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
        var json = OpcUaEndpointConfigSerializer.Serialize(config);
        Assert.Contains("\"endpointUrl\"", json);
        Assert.DoesNotContain("\"EndpointUrl\"", json);
    }

    [Fact]
    public void Serialize_SecurityModeAsCamelCaseString()
    {
        var config = new OpcUaEndpointConfig { SecurityMode = OpcUaSecurityMode.SignAndEncrypt };
        var json = OpcUaEndpointConfigSerializer.Serialize(config);
        Assert.Contains("\"securityMode\":\"signAndEncrypt\"", json);
    }

    [Fact]
    public void Deserialize_NullOrEmpty_ReturnsDefaults()
    {
        var (config1, legacy1) = OpcUaEndpointConfigSerializer.Deserialize(null);
        var (config2, legacy2) = OpcUaEndpointConfigSerializer.Deserialize("");
        var (config3, legacy3) = OpcUaEndpointConfigSerializer.Deserialize("   ");

        Assert.False(legacy1);
        Assert.False(legacy2);
        Assert.False(legacy3);
        Assert.Equal("", config1.EndpointUrl);
        Assert.Equal(60000, config1.SessionTimeoutMs);
        Assert.Null(config1.Heartbeat);
    }

    [Fact]
    public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields()
    {
        // The legacy shape that DeploymentManagerActor produced from raw JSON before this refactor.
        var legacyJson = """
            {
              "endpoint": "opc.tcp://legacy:4840",
              "SessionTimeoutMs": "45000",
              "SamplingIntervalMs": "500",
              "SecurityMode": "Sign",
              "AutoAcceptUntrustedCerts": "false",
              "HeartbeatTagPath": "Old.Heartbeat",
              "HeartbeatMaxSilence": "20"
            }
            """;

        var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);

        Assert.True(isLegacy);
        Assert.Equal("opc.tcp://legacy:4840", config.EndpointUrl);
        Assert.Equal(45000, config.SessionTimeoutMs);
        Assert.Equal(500, config.SamplingIntervalMs);
        Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
        Assert.False(config.AutoAcceptUntrustedCerts);
        Assert.NotNull(config.Heartbeat);
        Assert.Equal("Old.Heartbeat", config.Heartbeat!.TagPath);
        Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds);
    }

    [Fact]
    public void Deserialize_LegacyWithEndpointUrlPascalKey_Works()
    {
        // Some pre-refactor configs used "EndpointUrl" instead of "endpoint".
        var legacyJson = """{"EndpointUrl":"opc.tcp://x:4840"}""";
        var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
        Assert.True(isLegacy);
        Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
    }

    [Fact]
    public void ToFlatDict_OmitsNullHeartbeat()
    {
        var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
        var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);

        Assert.Equal("opc.tcp://x:4840", dict["endpoint"]);
        Assert.Equal("60000", dict["SessionTimeoutMs"]);
        Assert.Equal("None", dict["SecurityMode"]);
        Assert.Equal("True", dict["AutoAcceptUntrustedCerts"]);
        Assert.False(dict.ContainsKey("HeartbeatTagPath"));
        Assert.False(dict.ContainsKey("HeartbeatMaxSilence"));
    }

    [Fact]
    public void ToFlatDict_IncludesHeartbeat_WhenSet()
    {
        var config = new OpcUaEndpointConfig
        {
            EndpointUrl = "opc.tcp://x:4840",
            Heartbeat = new OpcUaHeartbeatConfig { TagPath = "HB.Tag", MaxSilenceSeconds = 45 }
        };
        var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);

        Assert.Equal("HB.Tag", dict["HeartbeatTagPath"]);
        Assert.Equal("45", dict["HeartbeatMaxSilence"]);
    }

    [Fact]
    public void FromFlatDict_RoundTripsAllKeys()
    {
        var dict = new Dictionary<string, string>
        {
            ["endpoint"] = "opc.tcp://x:4840",
            ["SessionTimeoutMs"] = "30000",
            ["SecurityMode"] = "Sign",
            ["HeartbeatTagPath"] = "Hb",
            ["HeartbeatMaxSilence"] = "15"
        };
        var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);

        Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
        Assert.Equal(30000, config.SessionTimeoutMs);
        Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
        Assert.NotNull(config.Heartbeat);
        Assert.Equal("Hb", config.Heartbeat!.TagPath);
    }
}

Step 2: Run the tests to verify failure

Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigSerializerTests Expected: build error — type OpcUaEndpointConfigSerializer does not exist.

Step 3: Commit the failing tests

git add tests/ScadaLink.Commons.Tests/Types/DataConnections/
git commit -m "test(commons): failing tests for OpcUaEndpointConfigSerializer"

Task 3: Implement OpcUaEndpointConfigSerializer

Files:

  • Create: src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs

Step 1: Implement the serializer

using System.Text.Json;
using System.Text.Json.Serialization;
using ScadaLink.Commons.Types.DataConnections;

namespace ScadaLink.Commons.Serialization;

/// <summary>
/// Serializes <see cref="OpcUaEndpointConfig"/> to/from the typed nested JSON
/// shape stored in <c>DataConnection.PrimaryConfiguration</c> / <c>BackupConfiguration</c>.
/// On read, falls back to the legacy flat string-dict shape for pre-refactor rows
/// and returns IsLegacy=true so the form can prompt the user to re-save.
/// </summary>
public static class OpcUaEndpointConfigSerializer
{
    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = false,
        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
    };

    public static string Serialize(OpcUaEndpointConfig config)
        => JsonSerializer.Serialize(config, JsonOpts);

    public static (OpcUaEndpointConfig Config, bool IsLegacy) Deserialize(string? json)
    {
        if (string.IsNullOrWhiteSpace(json))
            return (new OpcUaEndpointConfig(), false);

        try
        {
            using var doc = JsonDocument.Parse(json);
            if (doc.RootElement.ValueKind == JsonValueKind.Object
                && doc.RootElement.TryGetProperty("endpointUrl", out _))
            {
                var typed = JsonSerializer.Deserialize<OpcUaEndpointConfig>(json, JsonOpts);
                if (typed != null)
                    return (typed, false);
            }
        }
        catch (JsonException) { /* fall through to legacy */ }

        return (LoadLegacy(json!), IsLegacy: true);
    }

    /// <summary>
    /// Flattens the typed config to the IDictionary&lt;string,string&gt; shape that
    /// IDataConnection.ConnectAsync expects. Keys match the historical convention
    /// used by OpcUaDataConnection so the adapter can keep that interface.
    /// </summary>
    public static IDictionary<string, string> ToFlatDict(OpcUaEndpointConfig config)
    {
        var dict = new Dictionary<string, string>
        {
            ["endpoint"] = config.EndpointUrl,
            ["SecurityMode"] = config.SecurityMode.ToString(),
            ["AutoAcceptUntrustedCerts"] = config.AutoAcceptUntrustedCerts.ToString(),
            ["SessionTimeoutMs"] = config.SessionTimeoutMs.ToString(),
            ["OperationTimeoutMs"] = config.OperationTimeoutMs.ToString(),
            ["PublishingIntervalMs"] = config.PublishingIntervalMs.ToString(),
            ["SamplingIntervalMs"] = config.SamplingIntervalMs.ToString(),
            ["QueueSize"] = config.QueueSize.ToString(),
            ["KeepAliveCount"] = config.KeepAliveCount.ToString(),
            ["LifetimeCount"] = config.LifetimeCount.ToString(),
            ["MaxNotificationsPerPublish"] = config.MaxNotificationsPerPublish.ToString(),
        };
        if (config.Heartbeat is { } hb)
        {
            dict["HeartbeatTagPath"] = hb.TagPath;
            dict["HeartbeatMaxSilence"] = hb.MaxSilenceSeconds.ToString();
        }
        return dict;
    }

    public static OpcUaEndpointConfig FromFlatDict(IDictionary<string, string> dict)
    {
        var c = new OpcUaEndpointConfig
        {
            EndpointUrl = dict.TryGetValue("endpoint", out var ep) ? ep
                        : dict.TryGetValue("EndpointUrl", out var ep2) ? ep2 : "",
            SecurityMode = Enum.TryParse<OpcUaSecurityMode>(
                dict.GetValueOrDefault("SecurityMode"), ignoreCase: true, out var sm)
                ? sm : OpcUaSecurityMode.None,
            AutoAcceptUntrustedCerts = ParseBool(dict, "AutoAcceptUntrustedCerts", true),
            SessionTimeoutMs = ParseInt(dict, "SessionTimeoutMs", 60000),
            OperationTimeoutMs = ParseInt(dict, "OperationTimeoutMs", 15000),
            PublishingIntervalMs = ParseInt(dict, "PublishingIntervalMs", 1000),
            SamplingIntervalMs = ParseInt(dict, "SamplingIntervalMs", 1000),
            QueueSize = ParseInt(dict, "QueueSize", 10),
            KeepAliveCount = ParseInt(dict, "KeepAliveCount", 10),
            LifetimeCount = ParseInt(dict, "LifetimeCount", 30),
            MaxNotificationsPerPublish = ParseInt(dict, "MaxNotificationsPerPublish", 100),
        };
        if (dict.TryGetValue("HeartbeatTagPath", out var hbPath)
            && !string.IsNullOrWhiteSpace(hbPath))
        {
            c.Heartbeat = new OpcUaHeartbeatConfig
            {
                TagPath = hbPath,
                MaxSilenceSeconds = ParseInt(dict, "HeartbeatMaxSilence", 30)
            };
        }
        return c;
    }

    private static OpcUaEndpointConfig LoadLegacy(string json)
    {
        var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
                   ?? new Dictionary<string, string>();
        return FromFlatDict(dict);
    }

    private static int ParseInt(IDictionary<string, string> d, string key, int defaultValue)
        => d.TryGetValue(key, out var s) && int.TryParse(s, out var v) ? v : defaultValue;

    private static bool ParseBool(IDictionary<string, string> d, string key, bool defaultValue)
        => d.TryGetValue(key, out var s) && bool.TryParse(s, out var v) ? v : defaultValue;
}

Step 2: Run tests, verify they pass

Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigSerializerTests Expected: all 8 tests pass.

Step 3: Commit

git add src/ScadaLink.Commons/Serialization/
git commit -m "feat(commons): OpcUaEndpointConfigSerializer with legacy fallback + flat-dict interop"

Task 4: TDD — OpcUaEndpointConfigValidator tests (failing)

Files:

  • Create: tests/ScadaLink.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs

Step 1: Write the failing test file

using ScadaLink.Commons.Types.DataConnections;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.Commons.Validators;

namespace ScadaLink.Commons.Tests.Validators;

public class OpcUaEndpointConfigValidatorTests
{
    private static OpcUaEndpointConfig Valid() => new()
    {
        EndpointUrl = "opc.tcp://plant-a:4840",
        // Defaults satisfy the spec: Lifetime(30) >= 3 * KeepAlive(10).
    };

    [Fact]
    public void Validate_DefaultsWithValidUrl_IsValid()
    {
        var result = OpcUaEndpointConfigValidator.Validate(Valid());
        Assert.True(result.IsValid);
        Assert.Empty(result.Errors);
    }

    [Fact]
    public void Validate_MissingEndpointUrl_Fails()
    {
        var c = Valid();
        c.EndpointUrl = "";
        var r = OpcUaEndpointConfigValidator.Validate(c);
        Assert.False(r.IsValid);
        Assert.Contains(r.Errors, e =>
            e.EntityName == "EndpointUrl"
            && e.Category == ValidationCategory.ConnectionConfig
            && e.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
    }

    [Theory]
    [InlineData("http://x")]
    [InlineData("opc.tcp://")]
    [InlineData("not a url")]
    public void Validate_BadEndpointUrl_Fails(string url)
    {
        var c = Valid();
        c.EndpointUrl = url;
        var r = OpcUaEndpointConfigValidator.Validate(c);
        Assert.False(r.IsValid);
        Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl");
    }

    [Fact]
    public void Validate_LifetimeLessThanThreeTimesKeepAlive_Fails()
    {
        var c = Valid();
        c.KeepAliveCount = 10;
        c.LifetimeCount = 29; // 3*10 = 30; 29 < 30 → invalid
        var r = OpcUaEndpointConfigValidator.Validate(c);
        Assert.False(r.IsValid);
        Assert.Contains(r.Errors, e => e.EntityName == "LifetimeCount");
    }

    [Theory]
    [InlineData(nameof(OpcUaEndpointConfig.SessionTimeoutMs))]
    [InlineData(nameof(OpcUaEndpointConfig.OperationTimeoutMs))]
    [InlineData(nameof(OpcUaEndpointConfig.PublishingIntervalMs))]
    [InlineData(nameof(OpcUaEndpointConfig.SamplingIntervalMs))]
    public void Validate_NonPositiveTiming_Fails(string field)
    {
        var c = Valid();
        typeof(OpcUaEndpointConfig).GetProperty(field)!.SetValue(c, 0);
        var r = OpcUaEndpointConfigValidator.Validate(c);
        Assert.False(r.IsValid);
        Assert.Contains(r.Errors, e => e.EntityName == field);
    }

    [Fact]
    public void Validate_QueueSizeZero_Fails()
    {
        var c = Valid();
        c.QueueSize = 0;
        var r = OpcUaEndpointConfigValidator.Validate(c);
        Assert.Contains(r.Errors, e => e.EntityName == "QueueSize");
    }

    [Fact]
    public void Validate_HeartbeatEnabledNoTagPath_Fails()
    {
        var c = Valid();
        c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "", MaxSilenceSeconds = 30 };
        var r = OpcUaEndpointConfigValidator.Validate(c);
        Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.TagPath");
    }

    [Fact]
    public void Validate_HeartbeatNonPositiveSilence_Fails()
    {
        var c = Valid();
        c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 0 };
        var r = OpcUaEndpointConfigValidator.Validate(c);
        Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.MaxSilenceSeconds");
    }

    [Fact]
    public void Validate_FieldPrefix_AppliedToEveryError()
    {
        var c = Valid();
        c.EndpointUrl = "";
        c.QueueSize = 0;
        var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary.");
        Assert.All(r.Errors, e => Assert.StartsWith("Primary.", e.EntityName!));
        Assert.Contains(r.Errors, e => e.EntityName == "Primary.EndpointUrl");
        Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize");
    }
}

Step 2: Run, verify failure

Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigValidatorTests Expected: build error — OpcUaEndpointConfigValidator not defined.

Step 3: Commit

git add tests/ScadaLink.Commons.Tests/Validators/
git commit -m "test(commons): failing tests for OpcUaEndpointConfigValidator"

Task 5: Implement OpcUaEndpointConfigValidator

Files:

  • Create: src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs

Step 1: Implement

using ScadaLink.Commons.Types.DataConnections;
using ScadaLink.Commons.Types.Flattening;

namespace ScadaLink.Commons.Validators;

/// <summary>
/// Pure-function validator for <see cref="OpcUaEndpointConfig"/>. Errors carry
/// the offending property name in <see cref="ValidationEntry.EntityName"/>
/// (optionally prefixed, e.g. "Primary.EndpointUrl") so the form can render
/// per-field messages.
/// </summary>
public static class OpcUaEndpointConfigValidator
{
    public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "")
    {
        var errors = new List<ValidationEntry>();

        if (string.IsNullOrWhiteSpace(config.EndpointUrl))
            errors.Add(Err("EndpointUrl", "Endpoint URL is required."));
        else if (!Uri.TryCreate(config.EndpointUrl, UriKind.Absolute, out var uri)
                 || uri.Scheme != "opc.tcp"
                 || string.IsNullOrEmpty(uri.Host))
            errors.Add(Err("EndpointUrl", "Endpoint URL must be a valid opc.tcp:// URI."));

        if (config.SessionTimeoutMs <= 0)
            errors.Add(Err("SessionTimeoutMs", "Must be > 0."));
        if (config.OperationTimeoutMs <= 0)
            errors.Add(Err("OperationTimeoutMs", "Must be > 0."));
        if (config.PublishingIntervalMs <= 0)
            errors.Add(Err("PublishingIntervalMs", "Must be > 0."));
        if (config.SamplingIntervalMs <= 0)
            errors.Add(Err("SamplingIntervalMs", "Must be > 0."));
        if (config.QueueSize < 1)
            errors.Add(Err("QueueSize", "Must be ≥ 1."));
        if (config.KeepAliveCount < 1)
            errors.Add(Err("KeepAliveCount", "Must be ≥ 1."));
        if (config.LifetimeCount < config.KeepAliveCount * 3)
            errors.Add(Err("LifetimeCount",
                "Must be at least 3× KeepAliveCount per OPC UA spec."));
        if (config.MaxNotificationsPerPublish < 1)
            errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1."));

        if (config.Heartbeat is { } hb)
        {
            if (string.IsNullOrWhiteSpace(hb.TagPath))
                errors.Add(Err("Heartbeat.TagPath",
                    "Tag path is required when heartbeat is enabled."));
            if (hb.MaxSilenceSeconds <= 0)
                errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0."));
        }

        return errors.Count == 0
            ? ValidationResult.Success()
            : ValidationResult.FromErrors(errors.ToArray());

        ValidationEntry Err(string field, string message) =>
            ValidationEntry.Error(
                ValidationCategory.ConnectionConfig,
                message,
                entityName: $"{fieldPrefix}{field}");
    }
}

Step 2: Run tests, verify pass

Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigValidatorTests Expected: all 11 tests pass.

Step 3: Commit

git add src/ScadaLink.Commons/Validators/
git commit -m "feat(commons): OpcUaEndpointConfigValidator"

Task 6: Refactor OpcUaDataConnection.ConnectAsync

Files:

  • Modify: src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:44-115
  • Verify: tests/ScadaLink.DataConnectionLayer.Tests/ — any existing test that constructs a Dictionary<string,string> and passes it to ConnectAsync should keep working unchanged.

Step 1: Replace ConnectAsync, StartHeartbeatMonitorAsync, and remove ParseInt/ParseBool helpers

In OpcUaDataConnection.cs, replace lines 44-115 with:

public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
    var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails);

    _endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
        ? "opc.tcp://localhost:4840"
        : config.EndpointUrl;

    var options = new OpcUaConnectionOptions(
        SessionTimeoutMs: config.SessionTimeoutMs,
        OperationTimeoutMs: config.OperationTimeoutMs,
        PublishingIntervalMs: config.PublishingIntervalMs,
        KeepAliveCount: config.KeepAliveCount,
        LifetimeCount: config.LifetimeCount,
        MaxNotificationsPerPublish: config.MaxNotificationsPerPublish,
        SamplingIntervalMs: config.SamplingIntervalMs,
        QueueSize: config.QueueSize,
        SecurityMode: config.SecurityMode.ToString(),
        AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts);

    _status = ConnectionHealth.Connecting;

    _client = _clientFactory.Create();
    _client.ConnectionLost += OnClientConnectionLost;
    await _client.ConnectAsync(_endpointUrl, options, cancellationToken);

    _status = ConnectionHealth.Connected;
    _disconnectFired = false;
    _logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);

    await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken);
}

private async Task StartHeartbeatMonitorAsync(OpcUaHeartbeatConfig? heartbeat, CancellationToken cancellationToken)
{
    if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.TagPath))
        return;

    _staleMonitor?.Dispose();
    _staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds));
    _staleMonitor.Stale += () =>
    {
        _logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s",
            heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
        RaiseDisconnected();
    };

    try
    {
        _heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath,
            (_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
        _staleMonitor.Start();
        _logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence",
            heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active",
            heartbeat.TagPath);
        _staleMonitor.Dispose();
        _staleMonitor = null;
    }
}

Delete the two internal static helpers at lines 107-115 (ParseInt, ParseBool). Add using ScadaLink.Commons.Serialization; and using ScadaLink.Commons.Types.DataConnections; at the top.

Step 2: Run DCL test suite — confirm nothing broke

Run: dotnet test tests/ScadaLink.DataConnectionLayer.Tests/ScadaLink.DataConnectionLayer.Tests.csproj Expected: all green. Existing tests pass Dictionary<string,string> to ConnectAsync; FromFlatDict accepts the same keys (including endpoint and EndpointUrl casing variants).

Step 3: If a test was directly verifying ParseInt/ParseBool behavior, delete it

grep -n "ParseInt\|ParseBool" tests/ScadaLink.DataConnectionLayer.Tests/ -r — if it returns hits, delete those test cases. They were testing internals that no longer exist; the same behavior is now covered by OpcUaEndpointConfigSerializerTests.FromFlatDict_*.

Step 4: Commit

git add src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs
git add tests/ScadaLink.DataConnectionLayer.Tests/  # if any test edits
git commit -m "refactor(dcl): OpcUaDataConnection uses OpcUaEndpointConfig via FromFlatDict"

Task 7: Refactor DeploymentManagerActor.EnsureDclConnections

Files:

  • Modify: src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:411-468
  • Verify: tests/ScadaLink.SiteRuntime.Tests/ — particularly any test that hands a sample FlattenedConfiguration with a connection JSON.

Why this changes: The current code does dumb JsonDocument.EnumerateObject + prop.Value.ToString() to flatten the connection JSON into a Dictionary<string,string>. With the new typed nested JSON shape (containing a nested heartbeat object), that flattener produces "heartbeat" → "{\"tagPath\":\"...\"}" — broken. The serializer's Deserialize + ToFlatDict does it correctly and also handles the legacy shape.

Step 1: Replace the body of EnsureDclConnections

Replace lines 411-468 with:

/// <summary>
/// Sets up DCL connections from the flattened config (idempotent: tracks created connections).
/// </summary>
private void EnsureDclConnections(string configJson)
{
    if (_dclManager == null) return;

    try
    {
        var config = System.Text.Json.JsonSerializer.Deserialize<Commons.Types.Flattening.FlattenedConfiguration>(configJson);
        if (config?.Connections == null) return;

        foreach (var (name, connConfig) in config.Connections)
        {
            if (_createdConnections.Contains(name))
                continue;

            var primaryDetails = FlattenConnectionConfig(connConfig.Protocol, connConfig.ConfigurationJson);
            var backupDetails = string.IsNullOrEmpty(connConfig.BackupConfigurationJson)
                ? null
                : FlattenConnectionConfig(connConfig.Protocol, connConfig.BackupConfigurationJson);

            _dclManager.Tell(new Commons.Messages.DataConnection.CreateConnectionCommand(
                name, connConfig.Protocol, primaryDetails, backupDetails, connConfig.FailoverRetryCount));

            _createdConnections.Add(name);
            _logger.LogInformation(
                "Created DCL connection {Connection} (protocol={Protocol})",
                name, connConfig.Protocol);
        }
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Failed to parse flattened config for DCL connections");
    }
}

private static IDictionary<string, string> FlattenConnectionConfig(string protocol, string? json)
{
    if (string.IsNullOrEmpty(json))
        return new Dictionary<string, string>();

    if (string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase))
    {
        var (config, _) = Commons.Serialization.OpcUaEndpointConfigSerializer.Deserialize(json);
        return Commons.Serialization.OpcUaEndpointConfigSerializer.ToFlatDict(config);
    }

    // Fallback: assume legacy flat-dict shape for any future / unknown protocol.
    try
    {
        var dict = new Dictionary<string, string>();
        using var doc = System.Text.Json.JsonDocument.Parse(json);
        foreach (var prop in doc.RootElement.EnumerateObject())
            dict[prop.Name] = prop.Value.ToString();
        return dict;
    }
    catch
    {
        return new Dictionary<string, string>();
    }
}

Step 2: Run SiteRuntime tests

Run: dotnet test tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj Expected: green. Any existing test passing legacy-style JSON for an OPC UA connection still works because Deserialize handles both shapes.

Step 3: Commit

git add src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs
git commit -m "refactor(site-runtime): route OPC UA connection JSON through serializer"

Task 8: TDD — <OpcUaEndpointEditor> Blazor component tests (failing)

Files:

  • Create: tests/ScadaLink.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs

Step 1: Write the failing tests

using Bunit;
using ScadaLink.Commons.Types.DataConnections;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.Commons.Validators;
using OpcUaEndpointEditor = ScadaLink.CentralUI.Components.Forms.OpcUaEndpointEditor;

namespace ScadaLink.CentralUI.Tests.Forms;

public class OpcUaEndpointEditorTests : BunitContext
{
    [Fact]
    public void Renders_All_Four_Section_Labels()
    {
        var config = new OpcUaEndpointConfig();
        var cut = Render<OpcUaEndpointEditor>(p => p
            .Add(c => c.Config, config)
            .Add(c => c.Title, "Primary Endpoint"));

        Assert.Contains("Primary Endpoint", cut.Markup);
        Assert.Contains("Timing", cut.Markup);
        Assert.Contains("Subscription", cut.Markup);
        Assert.Contains("Heartbeat", cut.Markup);
    }

    [Fact]
    public void Binding_MutatesPassedConfigInstance()
    {
        var config = new OpcUaEndpointConfig();
        var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));

        cut.Find("input[type='text']").Change("opc.tcp://new-host:4840");

        Assert.Equal("opc.tcp://new-host:4840", config.EndpointUrl);
    }

    [Fact]
    public void EnableHeartbeat_CreatesSubObject()
    {
        var config = new OpcUaEndpointConfig();
        var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));

        Assert.Null(config.Heartbeat);
        cut.FindAll("button").First(b => b.TextContent.Contains("Enable Heartbeat")).Click();

        Assert.NotNull(config.Heartbeat);
    }

    [Fact]
    public void RemoveHeartbeat_NullsSubObject()
    {
        var config = new OpcUaEndpointConfig
        {
            Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 30 }
        };
        var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));

        cut.FindAll("button").First(b => b.TextContent.Contains("Remove Heartbeat")).Click();

        Assert.Null(config.Heartbeat);
    }

    [Fact]
    public void Errors_Parameter_RendersPerFieldRedText()
    {
        var config = new OpcUaEndpointConfig { EndpointUrl = "" };
        var errors = OpcUaEndpointConfigValidator.Validate(config, "Primary.");
        var cut = Render<OpcUaEndpointEditor>(p => p
            .Add(c => c.Config, config)
            .Add(c => c.Errors, errors));

        Assert.Contains("Endpoint URL is required.", cut.Markup);
        Assert.Contains("text-danger", cut.Markup);
    }

    [Fact]
    public void IsLegacy_True_RendersWarningBanner()
    {
        var cut = Render<OpcUaEndpointEditor>(p => p
            .Add(c => c.Config, new OpcUaEndpointConfig())
            .Add(c => c.IsLegacy, true));

        Assert.Contains("alert-warning", cut.Markup);
        Assert.Contains("migrated from a legacy format", cut.Markup);
    }
}

Step 2: Run, verify failure

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointEditorTests Expected: build error — OpcUaEndpointEditor does not exist.

Step 3: Commit

git add tests/ScadaLink.CentralUI.Tests/Forms/
git commit -m "test(ui): failing bUnit tests for OpcUaEndpointEditor"

Task 9: Implement <OpcUaEndpointEditor> Blazor component

Files:

  • Create: src/ScadaLink.CentralUI/Components/Forms/OpcUaEndpointEditor.razor

Step 1: Implement the component

@namespace ScadaLink.CentralUI.Components.Forms
@using ScadaLink.Commons.Types.DataConnections
@using ScadaLink.Commons.Types.Flattening

<div class="opcua-endpoint-editor">
    <h6 class="text-muted border-bottom pb-1">@Title</h6>

    @if (IsLegacy)
    {
        <div class="alert alert-warning py-1 small mb-2">
            This connection was migrated from a legacy format.
            Review the settings and Save to update.
        </div>
    }

    <div class="row g-2 mb-2">
        <div class="col-md-7">
            <label class="form-label small">Endpoint URL</label>
            <input type="text" class="form-control form-control-sm"
                   @bind="Config.EndpointUrl"
                   placeholder="opc.tcp://host:4840" />
            @RenderFieldError("EndpointUrl")
        </div>
        <div class="col-md-3">
            <label class="form-label small">Security Mode</label>
            <select class="form-select form-select-sm" @bind="Config.SecurityMode">
                <option value="@OpcUaSecurityMode.None">None</option>
                <option value="@OpcUaSecurityMode.Sign">Sign</option>
                <option value="@OpcUaSecurityMode.SignAndEncrypt">Sign &amp; Encrypt</option>
            </select>
        </div>
        <div class="col-md-2 d-flex align-items-end">
            <div class="form-check">
                <input class="form-check-input" type="checkbox"
                       id="@($"{IdPrefix}-autoaccept")"
                       @bind="Config.AutoAcceptUntrustedCerts" />
                <label class="form-check-label small"
                       for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
            </div>
        </div>
    </div>

    <div class="text-muted small mt-2 mb-1">Timing</div>
    <div class="row g-2 mb-2">
        <div class="col-md-3">
            <label class="form-label small">Session timeout (ms)</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.SessionTimeoutMs" min="1" />
            @RenderFieldError("SessionTimeoutMs")
        </div>
        <div class="col-md-3">
            <label class="form-label small">Operation timeout (ms)</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.OperationTimeoutMs" min="1" />
            @RenderFieldError("OperationTimeoutMs")
        </div>
    </div>

    <div class="text-muted small mt-2 mb-1">Subscription</div>
    <div class="row g-2 mb-2">
        <div class="col-md-3">
            <label class="form-label small">Publishing interval (ms)</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.PublishingIntervalMs" min="1" />
            @RenderFieldError("PublishingIntervalMs")
        </div>
        <div class="col-md-3">
            <label class="form-label small">Sampling interval (ms)</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.SamplingIntervalMs" min="1" />
            @RenderFieldError("SamplingIntervalMs")
        </div>
        <div class="col-md-2">
            <label class="form-label small">Queue size</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.QueueSize" min="1" />
            @RenderFieldError("QueueSize")
        </div>
        <div class="col-md-2">
            <label class="form-label small">Keep-alive count</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.KeepAliveCount" min="1" />
            @RenderFieldError("KeepAliveCount")
        </div>
        <div class="col-md-2">
            <label class="form-label small">Lifetime count</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.LifetimeCount" min="1" />
            @RenderFieldError("LifetimeCount")
        </div>
        <div class="col-md-3">
            <label class="form-label small">Max notifications / publish</label>
            <input type="number" class="form-control form-control-sm"
                   @bind="Config.MaxNotificationsPerPublish" min="1" />
            @RenderFieldError("MaxNotificationsPerPublish")
        </div>
    </div>

    <div class="text-muted small mt-2 mb-1">Heartbeat</div>
    @if (Config.Heartbeat is null)
    {
        <button type="button" class="btn btn-outline-secondary btn-sm mb-2"
                @onclick="EnableHeartbeat">Enable Heartbeat</button>
    }
    else
    {
        <div class="row g-2 mb-2">
            <div class="col-md-6">
                <label class="form-label small">Tag path</label>
                <input type="text" class="form-control form-control-sm"
                       @bind="Config.Heartbeat.TagPath"
                       placeholder="Sensors.Heartbeat" />
                @RenderFieldError("Heartbeat.TagPath")
            </div>
            <div class="col-md-3">
                <label class="form-label small">Max silence (s)</label>
                <input type="number" class="form-control form-control-sm"
                       @bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
                @RenderFieldError("Heartbeat.MaxSilenceSeconds")
            </div>
            <div class="col-md-3 d-flex align-items-end">
                <button type="button" class="btn btn-outline-danger btn-sm"
                        @onclick="() => Config.Heartbeat = null">
                    Remove Heartbeat
                </button>
            </div>
        </div>
    }
</div>

@code {
    [Parameter, EditorRequired] public OpcUaEndpointConfig Config { get; set; } = default!;
    [Parameter] public string Title { get; set; } = "Endpoint";
    [Parameter] public string IdPrefix { get; set; } = "endpoint";
    [Parameter] public bool IsLegacy { get; set; }
    [Parameter] public ValidationResult? Errors { get; set; }

    private void EnableHeartbeat() =>
        Config.Heartbeat = new OpcUaHeartbeatConfig();

    private RenderFragment? RenderFieldError(string field)
    {
        var match = Errors?.Errors.FirstOrDefault(e =>
            e.EntityName != null
            && (e.EntityName == field || e.EntityName.EndsWith("." + field)));
        return match is null
            ? null
            : @<div class="text-danger small">@match.Message</div>;
    }
}

Step 2: Run the editor tests, verify pass

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointEditorTests Expected: all 6 tests pass.

Step 3: Commit

git add src/ScadaLink.CentralUI/Components/Forms/
git commit -m "feat(ui): OpcUaEndpointEditor Blazor component"

Task 10: TDD — DataConnectionForm refactor tests (failing/updating)

Files:

  • Create or modify: tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs

Before writing, run: ls tests/ScadaLink.CentralUI.Tests/ | grep -i "DataConnection". If a test file already exists, modify it; otherwise create new.

Step 1: Write the failing tests

using System.Security.Claims;
using System.Text.Json;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using DataConnectionForm = ScadaLink.CentralUI.Components.Pages.Admin.DataConnectionForm;

namespace ScadaLink.CentralUI.Tests;

public class DataConnectionFormTests : BunitContext
{
    private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();

    public DataConnectionFormTests()
    {
        Services.AddSingleton(_siteRepo);
        AddTestAuth();
        var sites = new List<Site> { new("Plant-A", "plant-a") { Id = 1 } };
        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(sites));
    }

    private void AddTestAuth()
    {
        var claims = new[]
        {
            new Claim("Username", "tester"),
            new Claim(ClaimTypes.Role, "Admin")
        };
        var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
        Services.AddAuthorizationCore();
    }

    [Fact]
    public void NoProtocolDropdown_IsRendered()
    {
        var cut = Render<DataConnectionForm>(p => p.Add(f => f.SiteId, 1));
        Assert.DoesNotContain("Custom", cut.Markup);
        // OPC UA security mode dropdown does exist, but the protocol dropdown's
        // distinctive labels (e.g. an option saying "OPC UA" directly) should be gone.
        var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList();
        Assert.DoesNotContain(labels, l => l == "Protocol");
    }

    [Fact]
    public async Task Save_InvalidPrimaryUrl_DoesNotCallRepo()
    {
        var cut = Render<DataConnectionForm>(p => p.Add(f => f.SiteId, 1));
        cut.FindAll("input[type='text']")
           .First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
           .Change("not-a-url");

        // Name field
        cut.FindAll("input[type='text']")
           .First(i => i.GetAttribute("placeholder") is null
                       || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp"))
           .Change("My Connection");

        await cut.FindAll("button")
                 .First(b => b.TextContent.Trim() == "Save").ClickAsync(new());

        await _siteRepo.DidNotReceive().AddDataConnectionAsync(Arg.Any<DataConnection>());
        Assert.Contains("Endpoint URL must be a valid", cut.Markup);
    }

    [Fact]
    public async Task Save_ValidConfig_PersistsTypedJsonAndProtocolOpcUa()
    {
        DataConnection? captured = null;
        await _siteRepo.AddDataConnectionAsync(
            Arg.Do<DataConnection>(d => captured = d));

        var cut = Render<DataConnectionForm>(p => p.Add(f => f.SiteId, 1));

        // Name
        cut.FindAll("input[type='text']")
           .First(i => i.GetAttribute("placeholder") is null
                       || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp"))
           .Change("PLC-1");
        // Endpoint URL
        cut.FindAll("input[type='text']")
           .First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
           .Change("opc.tcp://plant-a:4840");

        await cut.FindAll("button")
                 .First(b => b.TextContent.Trim() == "Save").ClickAsync(new());

        Assert.NotNull(captured);
        Assert.Equal("OpcUa", captured!.Protocol);
        Assert.NotNull(captured.PrimaryConfiguration);

        using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!);
        Assert.Equal("opc.tcp://plant-a:4840",
            doc.RootElement.GetProperty("endpointUrl").GetString());
        Assert.Equal(60000,
            doc.RootElement.GetProperty("sessionTimeoutMs").GetInt32());
    }
}

Step 2: Run, verify failure

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~DataConnectionFormTests Expected: at least one test fails — current form still has the Protocol dropdown and stores raw textarea JSON, not typed-camelCase JSON.

Step 3: Commit

git add tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs
git commit -m "test(ui): failing tests for DataConnectionForm refactor"

Task 11: Refactor DataConnectionForm.razor

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor (whole file)

Step 1: Rewrite the form

Replace the entire file with:

@page "/admin/connections/create"
@page "/admin/connections/{Id:int}/edit"
@page "/admin/data-connections/create"
@page "/admin/data-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.DataConnections
@using ScadaLink.Commons.Types.Flattening
@using ScadaLink.Commons.Serialization
@using ScadaLink.Commons.Validators
@using ScadaLink.CentralUI.Components.Forms
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager

<div class="container-fluid mt-3">
    <div class="d-flex align-items-center mb-3">
        <button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
        <h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
    </div>

    @if (_loading)
    {
        <LoadingSpinner IsLoading="true" />
    }
    else
    {
        <div class="card mb-3">
            <div class="card-body">
                <div class="mb-2">
                    <label class="form-label small">Site</label>
                    @if (_siteLocked)
                    {
                        <input type="text" class="form-control form-control-sm" value="@_siteName" disabled />
                    }
                    else
                    {
                        <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-3">
                    <label class="form-label small">Name</label>
                    <input type="text" class="form-control form-control-sm" @bind="_formName" />
                </div>

                <OpcUaEndpointEditor Title="Primary Endpoint"
                                     IdPrefix="primary"
                                     Config="_primaryConfig"
                                     IsLegacy="_primaryIsLegacy"
                                     Errors="_primaryErrors" />

                <h6 class="text-muted border-bottom pb-1 mt-3">Backup Endpoint</h6>
                @if (!_showBackup)
                {
                    <div class="mb-3">
                        <button type="button" class="btn btn-outline-secondary btn-sm"
                                @onclick="EnableBackup">Add Backup Endpoint</button>
                    </div>
                }
                else
                {
                    <OpcUaEndpointEditor Title="Backup Endpoint"
                                         IdPrefix="backup"
                                         Config="_backupConfig"
                                         IsLegacy="_backupIsLegacy"
                                         Errors="_backupErrors" />
                    <div class="mb-2">
                        <label class="form-label small">Failover Retry Count</label>
                        <input type="number" class="form-control form-control-sm" style="max-width: 120px;"
                               min="1" max="20" @bind="_formFailoverRetryCount" />
                        <div class="form-text">Retries on active endpoint before switching to backup (default: 3)</div>
                    </div>
                    <div class="mb-3">
                        <button type="button" class="btn btn-outline-danger btn-sm"
                                @onclick="RemoveBackup">Remove Backup</button>
                    </div>
                }

                @if (_formError != null)
                {
                    <div class="text-danger small mt-2">@_formError</div>
                }
                <div class="mt-3">
                    <button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
                    <button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
                </div>
            </div>
        </div>
    }
</div>

@code {
    [Parameter] public int? Id { get; set; }
    [SupplyParameterFromQuery] public int? SiteId { get; set; }

    private bool _loading = true;
    private DataConnection? _editingConnection;
    private List<Site> _sites = new();
    private int _formSiteId;
    private string _siteName = string.Empty;
    private bool _siteLocked;
    private string _formName = string.Empty;
    private OpcUaEndpointConfig _primaryConfig = new();
    private OpcUaEndpointConfig _backupConfig = new();
    private bool _primaryIsLegacy;
    private bool _backupIsLegacy;
    private bool _showBackup;
    private int _formFailoverRetryCount = 3;
    private ValidationResult? _primaryErrors;
    private ValidationResult? _backupErrors;
    private string? _formError;

    protected override async Task OnInitializedAsync()
    {
        _sites = (await SiteRepository.GetAllSitesAsync()).ToList();

        if (Id.HasValue)
        {
            try
            {
                _editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
                if (_editingConnection != null)
                {
                    _formSiteId = _editingConnection.SiteId;
                    _siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
                    _siteLocked = true;
                    _formName = _editingConnection.Name;

                    (_primaryConfig, _primaryIsLegacy) =
                        OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);

                    if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
                    {
                        (_backupConfig, _backupIsLegacy) =
                            OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
                        _showBackup = true;
                        _formFailoverRetryCount = _editingConnection.FailoverRetryCount;
                    }
                }
            }
            catch (Exception ex)
            {
                _formError = $"Failed to load connection: {ex.Message}";
            }
        }
        else if (SiteId.HasValue)
        {
            var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
            if (site != null)
            {
                _formSiteId = site.Id;
                _siteName = site.Name;
                _siteLocked = true;
            }
        }

        _loading = false;
    }

    private async Task SaveConnection()
    {
        _formError = null;
        if (_formSiteId == 0) { _formError = "Site is required."; return; }
        if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }

        _primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
        _backupErrors = _showBackup
            ? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
            : null;

        if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
        {
            _formError = "Fix the errors below before saving.";
            return;
        }

        var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
        var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;

        try
        {
            if (_editingConnection != null)
            {
                _editingConnection.Name = _formName.Trim();
                _editingConnection.Protocol = "OpcUa";
                _editingConnection.PrimaryConfiguration = primaryJson;
                _editingConnection.BackupConfiguration = backupJson;
                _editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
                await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
            }
            else
            {
                var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
                {
                    PrimaryConfiguration = primaryJson,
                    BackupConfiguration = backupJson,
                    FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
                };
                await SiteRepository.AddDataConnectionAsync(conn);
            }
            await SiteRepository.SaveChangesAsync();
            NavigationManager.NavigateTo("/admin/connections");
        }
        catch (Exception ex)
        {
            _formError = $"Save failed: {ex.Message}";
        }
    }

    private void EnableBackup() => _showBackup = true;

    private void RemoveBackup()
    {
        _showBackup = false;
        _backupConfig = new OpcUaEndpointConfig();
        _backupIsLegacy = false;
        _formFailoverRetryCount = 3;
    }

    private void GoBack() => NavigationManager.NavigateTo("/admin/connections");
}

Step 2: Run the form tests, verify pass

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~DataConnectionFormTests Expected: all tests pass.

Step 3: Run the full CentralUI suite

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj Expected: all tests pass (the pre-existing DataConnectionsPageTests is unaffected; it tests the list page, not the form).

Step 4: Commit

git add src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor
git commit -m "refactor(ui/admin): DataConnectionForm uses OpcUaEndpointEditor and typed model"

Task 12: Full solution build + all test suites green

Step 1: Solution-wide build

Run: dotnet build Expected: build succeeded, 0 errors. Warnings acceptable.

Step 2: Solution-wide test

Run: dotnet test Expected: all test projects green. Likely-affected suites:

  • ScadaLink.Commons.Tests (+ new serializer & validator tests, ~19 new total)
  • ScadaLink.DataConnectionLayer.Tests (existing tests must still pass)
  • ScadaLink.SiteRuntime.Tests (existing tests must still pass)
  • ScadaLink.CentralUI.Tests (+ new editor tests, + new/updated form tests)

Step 3: If anything fails, do NOT proceed to Docker. Diagnose and fix in a new commit before moving on.

There is no commit in this task — it's a checkpoint.


Task 13: Docker deploy + browser smoke

Step 1: Rebuild and start the cluster

Run: bash docker/deploy.sh Expected: scadalink:latest rebuilt, 5 cluster containers up (central-a, central-b, site-a, site-b, site-c per docker/README.md).

Step 2: Open the connections page

URL: http://localhost:9000/admin/connections Login: multi-role / password Expected: existing test data shows 3 sites and ~6 OPC UA connections in the tree.

Step 3: Edit an existing legacy connection

Pick any pre-refactor connection (likely all of them on first deploy). Right-click → Edit, or use the connection row's Edit action. Expected:

  • "Primary Endpoint" header renders.
  • A yellow alert-warning banner reads "This connection was migrated from a legacy format..."
  • All fields are populated from the legacy JSON.
  • Click Save → returns to the list page.
  • Open the same connection again → the legacy banner is gone (it now uses the typed shape).

Step 4: Create a new connection with validation failure

From the list page, click a site row to select it, then click + Connection.

  • Leave Endpoint URL blank, fill Name = "test", click Save.
  • Expected: red text "Endpoint URL is required." under the URL field. No navigation.

Step 5: Create a new connection, valid

  • Endpoint URL: opc.tcp://plant-a:4840
  • Toggle "Enable Heartbeat" → Tag path: Sensors.Heartbeat, Max silence: 30
  • Add Backup Endpoint: opc.tcp://plant-a-backup:4840
  • Click Save → navigates back to the list page.
  • New connection appears under the selected site.

Step 6: Verify DB JSON shape

Run: docker exec scadalink-central-a sqlite3 /var/lib/scadalink/scadalink.db "SELECT PrimaryConfiguration FROM DataConnections ORDER BY Id DESC LIMIT 1;" Expected: typed nested JSON containing "endpointUrl", "securityMode", "heartbeat":{"tagPath":...}.

Step 7: Deploy a template that uses the connection

Optional but a strong smoke. Use any pre-existing template that has a connection binding. Navigate to /deployment/topology, deploy to the site that owns the new connection. Expected: site logs (docker logs scadalink-site-a) show "OPC UA connected to opc.tcp://..." — the runtime parsed the new JSON shape correctly.

Step 8: If anything's off, capture screenshots / logs, fix, and re-deploy before committing.

No commit yet; this is verification.


Task 14: Final commit & push

Step 1: Review the branch state

Run: git status and git log --oneline f3b33e7..HEAD Expected: ~7 commits from this plan (Task 1, 2, 3, 4, 5, 6, 7, 9, 10, 11 — some folded into a single commit each per the task instructions above).

Step 2: Push

Run: git push Expected: branch advances on origin; existing PR #1 picks up the new commits.

Step 3: Verify the PR

Open https://gitea.dohertylan.com/dohertj2/scadalink-design/pulls/1 and confirm the new commits are listed.


Out of scope (do NOT do)

  • No EF Core migration. Column types are unchanged.
  • No CLI changes. The data-connection CLI commands continue to pass through raw JSON; if a user passes legacy-dict JSON, the new serializer's legacy fallback handles it. (Note for future work: the CLI could grow --endpoint-url / --security-mode flags; out of scope here.)
  • No new protocols. "Custom" is gone from the UI; if/when a real Custom adapter lands, the form re-introduces a protocol dropdown.
  • No live (debounced) validation. Save-time only.
  • No "Verify endpoint" connectivity test button.
  • No requirements-doc rewrite. The design doc points at OpcUaEndpointConfig as the canonical schema; that's enough.