using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance; /// /// Integration tests for /// (M5.6 T5 — SourceNode sentinel backfill). /// /// /// These tests exercise the real against a /// per-class database, mirroring the /// style of PartitionPurgeTests. All tests are guarded with /// [SkippableFact] and skipped when the MSSQL container is absent. /// /// public class BackfillSourceNodeTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public BackfillSourceNodeTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } private ScadaBridgeDbContext CreateContext() => new(new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString).Options); private AuditLogRepository CreateRepo(ScadaBridgeDbContext ctx) => new(ctx); // ------------------------------------------------------------------ // Seed helper: direct INSERT bypassing the writer role, same pattern // as PartitionPurgeTests.DirectInsertAsync. // ------------------------------------------------------------------ private async Task SeedRowAsync( SqlConnection conn, Guid eventId, DateTime occurredAtUtc, string? sourceNode) { await using var cmd = conn.CreateCommand(); // Supply SourceNode explicitly (NULL or a value) so the test controls // which rows are eligible for backfill. cmd.CommandText = @" INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson) VALUES (@EventId, @OccurredAtUtc, NULL, 'ApiOutbound.ApiCall', 'Success', 'ApiOutbound', NULL, @SourceNode, NULL, @DetailsJson);"; cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId; var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2); occurredParam.Scale = 7; occurredParam.Value = occurredAtUtc; var sourceNodeParam = cmd.Parameters.Add("@SourceNode", System.Data.SqlDbType.VarChar, 64); sourceNodeParam.Value = (object?)sourceNode ?? DBNull.Value; var detailsJson = "{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," + "\"payloadTruncated\":false}"; cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson; await cmd.ExecuteNonQueryAsync(); } private async Task ReadSourceNodeAsync(SqlConnection conn, Guid eventId) { await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT SourceNode FROM dbo.AuditLog WHERE EventId = @EventId;"; cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId; var raw = await cmd.ExecuteScalarAsync(); return raw == DBNull.Value ? null : (string?)raw; } // ------------------------------------------------------------------ // 1. SetsNullRowsBeforeThreshold // ------------------------------------------------------------------ [SkippableFact] public async Task BackfillSourceNode_SetsNullRowsBeforeThreshold() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); var eligibleId = Guid.NewGuid(); // NULL, occurred before threshold var tooNewId = Guid.NewGuid(); // NULL, occurred after threshold await using var seedConn = _fixture.OpenConnection(); await SeedRowAsync(seedConn, eligibleId, new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), sourceNode: null); await SeedRowAsync(seedConn, tooNewId, new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), sourceNode: null); await using var ctx = CreateContext(); var repo = CreateRepo(ctx); var rows = await repo.BackfillSourceNodeAsync("unknown", before, batchSize: 1000); Assert.True(rows >= 1, $"Expected at least 1 row updated; got {rows}."); // eligible row: must now have the sentinel var eligibleNode = await ReadSourceNodeAsync(seedConn, eligibleId); Assert.Equal("unknown", eligibleNode); // too-new row: must still be NULL var tooNewNode = await ReadSourceNodeAsync(seedConn, tooNewId); Assert.Null(tooNewNode); } // ------------------------------------------------------------------ // 2. LeavesNonNullRowsUntouched // ------------------------------------------------------------------ [SkippableFact] public async Task BackfillSourceNode_LeavesNonNullRowsUntouched() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); var alreadySetId = Guid.NewGuid(); // already has a SourceNode value await using var seedConn = _fixture.OpenConnection(); await SeedRowAsync(seedConn, alreadySetId, new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc), sourceNode: "node-a"); await using var ctx = CreateContext(); var repo = CreateRepo(ctx); await repo.BackfillSourceNodeAsync("unknown", before, batchSize: 1000); // "node-a" must still be "node-a", not overwritten var node = await ReadSourceNodeAsync(seedConn, alreadySetId); Assert.Equal("node-a", node); } // ------------------------------------------------------------------ // 3. Idempotent_SecondRunUpdatesZeroRows // ------------------------------------------------------------------ [SkippableFact] public async Task BackfillSourceNode_Idempotent_SecondRunUpdatesZeroRows() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); var idempotentId = Guid.NewGuid(); await using var seedConn = _fixture.OpenConnection(); await SeedRowAsync(seedConn, idempotentId, new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc), sourceNode: null); await using var ctx1 = CreateContext(); var repo1 = CreateRepo(ctx1); var firstRun = await repo1.BackfillSourceNodeAsync("unknown", before, batchSize: 1000); Assert.True(firstRun >= 1, "First run should update at least 1 row."); // Second run: no NULL rows remain for this threshold — must update 0. await using var ctx2 = CreateContext(); var repo2 = CreateRepo(ctx2); var secondRun = await repo2.BackfillSourceNodeAsync("unknown", before, batchSize: 1000); // The second run must not update the already-sentinel row again. // We cannot assert exactly 0 because other tests share the same fixture DB // and may have left unrelated NULL rows; but the idempotentId row must not // have been touched (it already has "unknown", so the WHERE SourceNode IS NULL // filter excludes it). var node = await ReadSourceNodeAsync(seedConn, idempotentId); Assert.Equal("unknown", node); // The second run returning 0 would be true if no other NULL rows exist — // we assert the contract from the repo's perspective by checking the row. _ = secondRun; // acknowledged: value consumed } // ------------------------------------------------------------------ // 4. CustomSentinelIsWritten // ------------------------------------------------------------------ [SkippableFact] public async Task BackfillSourceNode_CustomSentinel_IsWritten() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var before = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); var customId = Guid.NewGuid(); await using var seedConn = _fixture.OpenConnection(); await SeedRowAsync(seedConn, customId, new DateTime(2026, 2, 5, 0, 0, 0, DateTimeKind.Utc), sourceNode: null); await using var ctx = CreateContext(); var repo = CreateRepo(ctx); await repo.BackfillSourceNodeAsync("pre-feature", before, batchSize: 1000); var node = await ReadSourceNodeAsync(seedConn, customId); Assert.Equal("pre-feature", node); } // ------------------------------------------------------------------ // 5. ArgumentValidation // ------------------------------------------------------------------ [Fact] public async Task BackfillSourceNode_EmptySentinel_Throws() { // Guard fires even without a DB connection — no Skip needed. // Use a null/empty context via a degenerate connection string; the // argument check fires before any SQL runs. await using var ctx = new ScadaBridgeDbContext( new DbContextOptionsBuilder() .UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;") .Options); var repo = new AuditLogRepository(ctx); await Assert.ThrowsAsync( () => repo.BackfillSourceNodeAsync("", DateTime.UtcNow, 1000)); } [Fact] public async Task BackfillSourceNode_ZeroBatchSize_Throws() { await using var ctx = new ScadaBridgeDbContext( new DbContextOptionsBuilder() .UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;") .Options); var repo = new AuditLogRepository(ctx); await Assert.ThrowsAsync( () => repo.BackfillSourceNodeAsync("unknown", DateTime.UtcNow, 0)); } }