238 lines
9.9 KiB
C#
238 lines
9.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Integration tests for <see cref="AuditLogRepository.BackfillSourceNodeAsync"/>
|
|
/// (M5.6 T5 — SourceNode sentinel backfill).
|
|
///
|
|
/// <para>
|
|
/// These tests exercise the real <see cref="AuditLogRepository"/> against a
|
|
/// per-class <see cref="MsSqlMigrationFixture"/> database, mirroring the
|
|
/// style of <c>PartitionPurgeTests</c>. All tests are guarded with
|
|
/// <c>[SkippableFact]</c> and skipped when the MSSQL container is absent.
|
|
/// </para>
|
|
/// </summary>
|
|
public class BackfillSourceNodeTests : IClassFixture<MsSqlMigrationFixture>
|
|
{
|
|
private readonly MsSqlMigrationFixture _fixture;
|
|
|
|
public BackfillSourceNodeTests(MsSqlMigrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
private ScadaBridgeDbContext CreateContext() =>
|
|
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.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<string?> 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<ScadaBridgeDbContext>()
|
|
.UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;")
|
|
.Options);
|
|
var repo = new AuditLogRepository(ctx);
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>(
|
|
() => repo.BackfillSourceNodeAsync("", DateTime.UtcNow, 1000));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BackfillSourceNode_ZeroBatchSize_Throws()
|
|
{
|
|
await using var ctx = new ScadaBridgeDbContext(
|
|
new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;")
|
|
.Options);
|
|
var repo = new AuditLogRepository(ctx);
|
|
|
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
|
() => repo.BackfillSourceNodeAsync("unknown", DateTime.UtcNow, 0));
|
|
}
|
|
}
|