Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs
T

259 lines
10 KiB
C#

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);
}
[SkippableFact]
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
Assert.True(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
// CAS: a second caller loses because the row is no longer Pending.
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
await using var readContext = CreateContext();
var row = await new SecuredWriteRepository(readContext).GetAsync(id);
Assert.NotNull(row);
Assert.Equal("Expired", row!.Status);
Assert.NotNull(row.DecidedAtUtc);
Assert.Null(row.VerifierUser); // system decision, no verifier
}
[SkippableFact]
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
var id = await repo.AddAsync(NewRow(siteId, status: "Pending"));
Assert.True(await repo.TryMarkApprovedAsync(id, "ver.bob", null, DateTime.UtcNow));
// Already Approved -> the conditional WHERE Status='Pending' matches no row.
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
}
[SkippableFact]
public async Task CountAsync_FiltersLikeQueryAsync()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(siteId, status: "Pending"));
await repo.AddAsync(NewRow(siteId, status: "Pending"));
Assert.Equal(2, await repo.CountAsync(status: "Pending", siteId: siteId));
Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId));
}
[SkippableFact]
public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Distinct per-run site identifiers stand in for "SITE1"/"SITE2"/"SITE3" so the
// permitted-set filter is asserted deterministically against a shared MSSQL.
var site1 = NewSiteId();
var site2 = NewSiteId();
var site3 = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site2, status: "Pending"));
await repo.AddAsync(NewRow(site3, status: "Pending"));
var rows = await repo.QueryAsync(status: null, siteId: null, skip: 0, take: 100,
permittedSiteIds: new[] { site1, site3 });
Assert.Equal(3, rows.Count);
Assert.DoesNotContain(rows, r => r.SiteId == site2);
}
[SkippableFact]
public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site1 = NewSiteId();
var site2 = NewSiteId();
var site3 = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site2, status: "Pending"));
await repo.AddAsync(NewRow(site3, status: "Pending"));
var count = await repo.CountAsync(status: null, siteId: null,
permittedSiteIds: new[] { site1, site3 });
Assert.Equal(3, count);
}
[SkippableFact]
public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// 4 rows on a single unique site; null permittedSiteIds must not filter them
// out — exactly today's behavior.
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(siteId, status: "Pending"));
await repo.AddAsync(NewRow(siteId, status: "Pending"));
await repo.AddAsync(NewRow(siteId, status: "Executed"));
await repo.AddAsync(NewRow(siteId, status: "Rejected"));
var rows = await repo.QueryAsync(status: null, siteId: siteId, skip: 0, take: 100,
permittedSiteIds: null);
Assert.Equal(4, rows.Count);
}
// --- 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,
};
}