feat(db): PendingSecuredWrite entity + migration + repository (T14b)

This commit is contained in:
Joseph Doherty
2026-06-18 02:09:31 -04:00
parent a0ce8b6c44
commit c799f41d53
10 changed files with 2477 additions and 0 deletions
@@ -0,0 +1,71 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
/// <summary>
/// Central operational state row for a two-person ("secured") write through its
/// lifecycle (M7 OPC UA / MxGateway UX, Task T14b). One row per pending write in the
/// central <c>PendingSecuredWrites</c> MS SQL table — append-once at submission then
/// mutated as the request is approved/rejected and executed against the target.
/// </summary>
/// <remarks>
/// <para>
/// Persistence-ignorant POCO; the EF Core mapping lives in the Configuration Database
/// component (<c>PendingSecuredWriteEntityTypeConfiguration</c>). Unlike the partitioned
/// append-only <c>AuditLog</c> this entity backs mutable operational state on a standard
/// non-partitioned table on the <c>[PRIMARY]</c> filegroup; no DB-role restriction
/// applies. It mirrors the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/>
/// entity/config/repository shape.
/// </para>
/// <para>
/// All timestamps are UTC, like every timestamp in the system.
/// </para>
/// </remarks>
public sealed class PendingSecuredWrite
{
/// <summary>Surrogate identity key assigned by the store.</summary>
public long Id { get; set; }
/// <summary>Site id the secured write targets.</summary>
public required string SiteId { get; set; }
/// <summary>Data connection name within the site the write is routed through.</summary>
public required string ConnectionName { get; set; }
/// <summary>Fully-qualified tag path the value is written to.</summary>
public required string TagPath { get; set; }
/// <summary>JSON-serialised value to write (interpreted per <see cref="ValueType"/>).</summary>
public required string ValueJson { get; set; }
/// <summary>The target data type name (e.g. <c>Boolean</c>, <c>Double</c>, <c>String</c>).</summary>
public required string ValueType { get; set; }
/// <summary>
/// Lifecycle status — one of
/// <c>Pending|Approved|Rejected|Executed|Failed|Expired</c>.
/// </summary>
public required string Status { get; set; }
/// <summary>The operator who submitted (requested) the secured write.</summary>
public required string OperatorUser { get; set; }
/// <summary>Optional free-text comment supplied by the requesting operator.</summary>
public string? OperatorComment { get; set; }
/// <summary>UTC instant the secured write was submitted.</summary>
public required DateTime SubmittedAtUtc { get; set; }
/// <summary>The verifier who approved/rejected the write; <c>null</c> while pending.</summary>
public string? VerifierUser { get; set; }
/// <summary>Optional free-text comment supplied by the verifier on decision.</summary>
public string? VerifierComment { get; set; }
/// <summary>UTC instant the write was approved/rejected; <c>null</c> while pending.</summary>
public DateTime? DecidedAtUtc { get; set; }
/// <summary>UTC instant the approved write was executed against the target; <c>null</c> until executed.</summary>
public DateTime? ExecutedAtUtc { get; set; }
/// <summary>Most recent execution error message; <c>null</c> when no failure has occurred.</summary>
public string? ExecutionError { get; set; }
}
@@ -0,0 +1,57 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Operational-state data access for the central <c>PendingSecuredWrites</c> table
/// (M7 OPC UA / MxGateway UX, Task T14b). One row per pending two-person secured
/// write; rows are inserted at submission and mutated as the request is decided and
/// executed. Mirrors the <c>SiteCalls</c> (Site Call Audit #22) repository shape.
/// </summary>
public interface ISecuredWriteRepository
{
/// <summary>
/// Inserts <paramref name="securedWrite"/> and returns the store-generated
/// <see cref="PendingSecuredWrite.Id"/>.
/// </summary>
/// <param name="securedWrite">The pending secured write to persist.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the generated identity of the inserted row.</returns>
Task<long> AddAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default);
/// <summary>
/// Returns the row for the given id, or <c>null</c> if none exists.
/// </summary>
/// <param name="id">The identity to look up.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the matching <see cref="PendingSecuredWrite"/>, or <c>null</c> if no row exists.</returns>
Task<PendingSecuredWrite?> GetAsync(long id, CancellationToken ct = default);
/// <summary>
/// Persists the current state of <paramref name="securedWrite"/> (matched by
/// <see cref="PendingSecuredWrite.Id"/>).
/// </summary>
/// <param name="securedWrite">The tracked entity whose changes to persist.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UpdateAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default);
/// <summary>
/// Returns up to <paramref name="take"/> rows (skipping <paramref name="skip"/>)
/// optionally filtered by <paramref name="status"/> and <paramref name="siteId"/>,
/// ordered by <c>SubmittedAtUtc DESC, Id DESC</c>. A <c>null</c> filter argument
/// matches every row.
/// </summary>
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
/// <param name="skip">Number of rows to skip (offset paging).</param>
/// <param name="take">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a page of matching rows, newest submission first.</returns>
Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
string? status,
string? siteId,
int skip,
int take,
CancellationToken ct = default);
}
@@ -0,0 +1,99 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="PendingSecuredWrite"/> entity to the central
/// <c>PendingSecuredWrites</c> table (M7 OPC UA / MxGateway UX, Task T14b).
/// Operational (mutable) state — NOT audit — so the table is non-partitioned,
/// standard <c>[PRIMARY]</c> filegroup, no DB-role restriction. Two named indexes
/// back the Central UI's "pending in this status, newest first" and "writes for
/// this site" queries. Mirrors <c>SiteCallEntityTypeConfiguration</c>.
/// </summary>
public class PendingSecuredWriteEntityTypeConfiguration : IEntityTypeConfiguration<PendingSecuredWrite>
{
/// <summary>
/// Configures the EF Core entity type mapping for <see cref="PendingSecuredWrite"/>.
/// </summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<PendingSecuredWrite> builder)
{
builder.ToTable("PendingSecuredWrites");
// Surrogate identity key (bigint IDENTITY) — generated on insert.
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.ValueGeneratedOnAdd();
// Bounded ASCII identifier columns.
builder.Property(p => p.SiteId)
.HasMaxLength(128)
.IsUnicode(false)
.IsRequired();
builder.Property(p => p.ConnectionName)
.HasMaxLength(128)
.IsUnicode(false)
.IsRequired();
builder.Property(p => p.TagPath)
.HasMaxLength(512)
.IsUnicode(false)
.IsRequired();
builder.Property(p => p.ValueType)
.HasMaxLength(128)
.IsUnicode(false)
.IsRequired();
// Enum-as-string lifecycle column (Pending|Approved|Rejected|Executed|Failed|Expired).
builder.Property(p => p.Status)
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(p => p.OperatorUser)
.HasMaxLength(256)
.IsUnicode(false)
.IsRequired();
builder.Property(p => p.VerifierUser)
.HasMaxLength(256)
.IsUnicode(false);
// Larger ASCII payload column — JSON-serialised value to write.
builder.Property(p => p.ValueJson)
.HasMaxLength(4000)
.IsUnicode(false)
.IsRequired();
// Operator-facing free-text comments; ASCII, bounded.
builder.Property(p => p.OperatorComment)
.HasMaxLength(1024)
.IsUnicode(false);
builder.Property(p => p.VerifierComment)
.HasMaxLength(1024)
.IsUnicode(false);
// Execution error detail; ASCII, bounded a bit larger to capture target faults.
builder.Property(p => p.ExecutionError)
.HasMaxLength(2048)
.IsUnicode(false);
// Timestamps are UTC datetime2 throughout (EF default for DateTime on SQL Server).
builder.Property(p => p.SubmittedAtUtc)
.IsRequired();
// Indexes — names locked for migration/operational discoverability.
// Status_Submitted backs "pending writes in this status, newest first".
builder.HasIndex(p => new { p.Status, p.SubmittedAtUtc })
.HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted");
// Site backs "secured writes for this site".
builder.HasIndex(p => p.SiteId)
.HasDatabaseName("IX_PendingSecuredWrites_Site");
}
}
@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddPendingSecuredWriteTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PendingSecuredWrites",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
SiteId = table.Column<string>(type: "varchar(128)", unicode: false, maxLength: 128, nullable: false),
ConnectionName = table.Column<string>(type: "varchar(128)", unicode: false, maxLength: 128, nullable: false),
TagPath = table.Column<string>(type: "varchar(512)", unicode: false, maxLength: 512, nullable: false),
ValueJson = table.Column<string>(type: "varchar(4000)", unicode: false, maxLength: 4000, nullable: false),
ValueType = table.Column<string>(type: "varchar(128)", unicode: false, maxLength: 128, nullable: false),
Status = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
OperatorUser = table.Column<string>(type: "varchar(256)", unicode: false, maxLength: 256, nullable: false),
OperatorComment = table.Column<string>(type: "varchar(1024)", unicode: false, maxLength: 1024, nullable: true),
SubmittedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
VerifierUser = table.Column<string>(type: "varchar(256)", unicode: false, maxLength: 256, nullable: true),
VerifierComment = table.Column<string>(type: "varchar(1024)", unicode: false, maxLength: 1024, nullable: true),
DecidedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
ExecutedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
ExecutionError = table.Column<string>(type: "varchar(2048)", unicode: false, maxLength: 2048, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PendingSecuredWrites", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PendingSecuredWrites_Site",
table: "PendingSecuredWrites",
column: "SiteId");
migrationBuilder.CreateIndex(
name: "IX_PendingSecuredWrites_Status_Submitted",
table: "PendingSecuredWrites",
columns: new[] { "Status", "SubmittedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PendingSecuredWrites");
}
}
}
@@ -927,6 +927,96 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("SharedScripts");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites.PendingSecuredWrite", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ConnectionName")
.IsRequired()
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<DateTime?>("DecidedAtUtc")
.HasColumnType("datetime2");
b.Property<DateTime?>("ExecutedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("ExecutionError")
.HasMaxLength(2048)
.IsUnicode(false)
.HasColumnType("varchar(2048)");
b.Property<string>("OperatorComment")
.HasMaxLength(1024)
.IsUnicode(false)
.HasColumnType("varchar(1024)");
b.Property<string>("OperatorUser")
.IsRequired()
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("varchar(256)");
b.Property<string>("SiteId")
.IsRequired()
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<DateTime>("SubmittedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("TagPath")
.IsRequired()
.HasMaxLength(512)
.IsUnicode(false)
.HasColumnType("varchar(512)");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4000)
.IsUnicode(false)
.HasColumnType("varchar(4000)");
b.Property<string>("ValueType")
.IsRequired()
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("VerifierComment")
.HasMaxLength(1024)
.IsUnicode(false)
.HasColumnType("varchar(1024)");
b.Property<string>("VerifierUser")
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("SiteId")
.HasDatabaseName("IX_PendingSecuredWrites_Site");
b.HasIndex("Status", "SubmittedAtUtc")
.HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted");
b.ToTable("PendingSecuredWrites", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b =>
{
b.Property<int>("Id")
@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="ISecuredWriteRepository"/> over the central
/// <c>PendingSecuredWrites</c> table (M7 OPC UA / MxGateway UX, Task T14b). Mirrors the
/// <c>SiteCallAuditRepository</c> data-access shape: plain tracked EF reads/writes
/// against the shared <see cref="ScadaBridgeDbContext"/>, no raw SQL needed.
/// </summary>
public class SecuredWriteRepository : ISecuredWriteRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="SecuredWriteRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
public SecuredWriteRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<long> AddAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(securedWrite);
await _context.Set<PendingSecuredWrite>().AddAsync(securedWrite, ct);
await _context.SaveChangesAsync(ct);
return securedWrite.Id;
}
/// <inheritdoc />
public async Task<PendingSecuredWrite?> GetAsync(long id, CancellationToken ct = default)
{
return await _context.Set<PendingSecuredWrite>()
.FindAsync(new object?[] { id }, ct);
}
/// <inheritdoc />
public async Task UpdateAsync(PendingSecuredWrite securedWrite, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(securedWrite);
// The caller hands back the same tracked instance it read; Update covers the
// detached case too (re-attaches and marks every column modified).
_context.Set<PendingSecuredWrite>().Update(securedWrite);
await _context.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
string? status,
string? siteId,
int skip,
int take,
CancellationToken ct = default)
{
IQueryable<PendingSecuredWrite> query = _context.Set<PendingSecuredWrite>()
.AsNoTracking();
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(p => p.Status == status);
}
if (!string.IsNullOrWhiteSpace(siteId))
{
query = query.Where(p => p.SiteId == siteId);
}
return await query
.OrderByDescending(p => p.SubmittedAtUtc)
.ThenByDescending(p => p.Id)
.Skip(skip)
.Take(take)
.ToListAsync(ct);
}
}
@@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
@@ -131,6 +132,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
/// <summary>Gets the set of site calls.</summary>
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
// Secured Writes (M7 OPC UA / MxGateway UX, T14b)
/// <summary>Gets the set of pending two-person secured writes.</summary>
public DbSet<PendingSecuredWrite> PendingSecuredWrites => Set<PendingSecuredWrite>();
// KPI History (M6 "KPI History & Trends")
/// <summary>Gets the set of KPI samples (central tall/EAV KPI-history backbone).</summary>
public DbSet<KpiSample> KpiSamples => Set<KpiSample>();
@@ -54,6 +54,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
services.AddScoped<ISecuredWriteRepository, SecuredWriteRepository>();
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
// Auth re-arch (C5): inbound API keys are no longer persisted in SQL Server —
// the repository now exposes only API-method access, so a plain scoped
@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Integration tests for <see cref="SecuredWriteRepository"/> (M7 OPC UA / MxGateway
/// UX, Task T14b). Uses the same <see cref="MsSqlMigrationFixture"/> as the migration
/// tests so the EF reads/writes execute against the real <c>PendingSecuredWrites</c>
/// schema produced by the migration. Each test mints a fresh per-test
/// <c>SiteId</c>/<c>Status</c> suffix so tests neither collide nor require teardown.
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so the
/// runner reports them as Skipped (not Passed) when MSSQL is unreachable.
/// </summary>
public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public SecuredWriteRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task Lifecycle_AddGetUpdateQuery_RoundTrips()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// Add a Pending row.
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
var pending = NewRow(siteId, status: "Pending");
var id = await repo.AddAsync(pending);
Assert.True(id > 0, "AddAsync should return the store-generated identity.");
// Get returns it.
await using (var readContext = CreateContext())
{
var loaded = await new SecuredWriteRepository(readContext).GetAsync(id);
Assert.NotNull(loaded);
Assert.Equal(siteId, loaded!.SiteId);
Assert.Equal("Pending", loaded.Status);
Assert.Equal("conn-1", loaded.ConnectionName);
Assert.Equal("Plant.Tank.Setpoint", loaded.TagPath);
Assert.Equal("op.alice", loaded.OperatorUser);
Assert.Null(loaded.VerifierUser);
Assert.Null(loaded.DecidedAtUtc);
}
// Update to Approved.
await using (var updateContext = CreateContext())
{
var updateRepo = new SecuredWriteRepository(updateContext);
var toApprove = await updateRepo.GetAsync(id);
Assert.NotNull(toApprove);
toApprove!.Status = "Approved";
toApprove.VerifierUser = "ver.bob";
toApprove.VerifierComment = "looks good";
toApprove.DecidedAtUtc = DateTime.UtcNow;
await updateRepo.UpdateAsync(toApprove);
}
// Get reflects the update.
await using (var verifyContext = CreateContext())
{
var reloaded = await new SecuredWriteRepository(verifyContext).GetAsync(id);
Assert.NotNull(reloaded);
Assert.Equal("Approved", reloaded!.Status);
Assert.Equal("ver.bob", reloaded.VerifierUser);
Assert.Equal("looks good", reloaded.VerifierComment);
Assert.NotNull(reloaded.DecidedAtUtc);
}
// Query by status returns it.
await using (var queryContext = CreateContext())
{
var queryRepo = new SecuredWriteRepository(queryContext);
var byStatus = await queryRepo.QueryAsync(status: "Approved", siteId: siteId, skip: 0, take: 50);
Assert.Contains(byStatus, p => p.Id == id);
Assert.All(byStatus, p => Assert.Equal("Approved", p.Status));
// A non-matching status filter excludes the row.
var pendingPage = await queryRepo.QueryAsync(status: "Pending", siteId: siteId, skip: 0, take: 50);
Assert.DoesNotContain(pendingPage, p => p.Id == id);
}
}
[SkippableFact]
public async Task QueryAsync_NullFilters_MatchEveryStatusForSite()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
var pendingId = await repo.AddAsync(NewRow(siteId, status: "Pending"));
var executedId = await repo.AddAsync(NewRow(siteId, status: "Executed"));
var all = await repo.QueryAsync(status: null, siteId: siteId, skip: 0, take: 50);
Assert.Contains(all, p => p.Id == pendingId);
Assert.Contains(all, p => p.Id == executedId);
}
// --- helpers ------------------------------------------------------------
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static string NewSiteId() =>
"site-t14b-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static PendingSecuredWrite NewRow(string siteId, string status) => new()
{
SiteId = siteId,
ConnectionName = "conn-1",
TagPath = "Plant.Tank.Setpoint",
ValueJson = "42.5",
ValueType = "Double",
Status = status,
OperatorUser = "op.alice",
OperatorComment = "raise setpoint",
SubmittedAtUtc = DateTime.UtcNow,
};
}