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; /// /// Integration tests for (M7 OPC UA / MxGateway /// UX, Task T14b). Uses the same as the migration /// tests so the EF reads/writes execute against the real PendingSecuredWrites /// schema produced by the migration. Each test mints a fresh per-test /// SiteId/Status suffix so tests neither collide nor require teardown. /// Tests pair with Skip.IfNot(...) so the /// runner reports them as Skipped (not Passed) when MSSQL is unreachable. /// public class SecuredWriteRepositoryTests : IClassFixture { 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() .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, }; }