using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; /// /// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch /// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct /// INSERTs that bypass the standard repository ingest path (so the seed /// timestamps are explicit), drives against /// the real + per-test /// database, and asserts: /// /// The oldest partition (Jan) is removed. /// Newer partitions (Feb + Mar) are untouched. /// The UX_AuditLog_EventId unique index survives the /// drop-and-rebuild dance. /// remains /// idempotent against the rebuilt index after the purge. /// /// /// /// The brief calls out that direct INSERTs bypass the writer role's INSERT-only /// grant; the fixture connects as sa (see /// 's default admin connection string), so /// the seed step does not need the writer role at all. The drop-and-rebuild /// dance itself runs under the same admin connection because the test owns /// the database — the role granularity is exercised in the repository tests, /// not here. /// public class PartitionPurgeTests : TestKit, IClassFixture { private readonly MsSqlMigrationFixture _fixture; public PartitionPurgeTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } private ScadaBridgeDbContext CreateContext() => new(new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString).Options); /// /// Direct INSERT into dbo.AuditLog bypassing /// . Used by the /// seed step so the test can place rows in arbitrary partitions without /// the repository's idempotency wrapper or ingest-stamping behaviour /// affecting the seed payload. /// private async Task DirectInsertAsync( SqlConnection conn, Guid eventId, DateTime occurredAtUtc, string siteId) { await using var cmd = conn.CreateCommand(); // C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson; // the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in // DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive. // Action = "{channel}.{kind}", Category = channel name, Outcome = Success. 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, NULL, NULL, @DetailsJson);"; cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId; // SqlDbType.DateTime2 with explicit Scale 7 matches the // OccurredAtUtc column shape (datetime2(7)) and avoids the implicit // narrowing that SqlClient's default DateTime → datetime applies via // AddWithValue. Critical for partition assignment: the partition // function key column is datetime2(7); a narrowed value would still // land in the correct partition for first-of-month seeds, but // explicit typing here documents the intent and matches how the // production repository INSERT shapes its parameters. var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2); occurredParam.Scale = 7; occurredParam.Value = occurredAtUtc; // DetailsJson carries the camelCase domain fields (matching AuditDetailsCodec): // channel/kind/status drive the computed Kind/Status columns; sourceSiteId drives // the computed SourceSiteId column the verify queries scope on. payloadTruncated // is always present (the codec always writes the bool). var detailsJson = "{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," + "\"sourceSiteId\":\"" + siteId + "\",\"payloadTruncated\":false}"; cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson; await cmd.ExecuteNonQueryAsync(); } /// /// Asserts that UX_AuditLog_EventId exists in /// sys.indexes. The drop-and-rebuild dance briefly removes the /// index inside its transaction; this check is meant to fire AFTER the /// actor's purge tick has committed so the rebuilt index is observable. /// private static async Task AssertUxIndexExistsAsync(SqlConnection conn) { await using var cmd = conn.CreateCommand(); cmd.CommandText = @" SELECT COUNT(*) FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');"; var raw = await cmd.ExecuteScalarAsync(); var count = Convert.ToInt32(raw); Assert.True(count == 1, $"UX_AuditLog_EventId should be present post-purge; sys.indexes count was {count}."); } private IActorRef CreateActor( IServiceProvider sp, AuditLogPurgeOptions purgeOptions, AuditLogOptions auditOptions) { return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( sp, Options.Create(purgeOptions), Options.Create(auditOptions), NullLogger.Instance))); } private static (DateTime Jan, DateTime Feb, DateTime Mar) SeedOccurredAt() => ( new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc)); // --------------------------------------------------------------------- // 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept // --------------------------------------------------------------------- [SkippableFact] public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Test date is ~2026-05-20 per environment. We want a threshold that // sits strictly between Jan 15 (the Jan partition's MAX) and Feb 15 // (the Feb partition's MAX) so only the Jan-2026 partition is // eligible for purge. RetentionDays = 100 gives a threshold of // ~2026-02-09 — Jan 15 is older (purged), Feb 15 and Mar 15 are // newer (kept). The window between Jan 15 and Feb 15 is wide enough // (~30 days) to tolerate any plausible test-clock drift in CI. var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8); var janEventId = Guid.NewGuid(); var febEventId = Guid.NewGuid(); var marEventId = Guid.NewGuid(); var (janOccurred, febOccurred, marOccurred) = SeedOccurredAt(); await using (var seedConn = _fixture.OpenConnection()) { await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId); await DirectInsertAsync(seedConn, febEventId, febOccurred, siteId); await DirectInsertAsync(seedConn, marEventId, marOccurred, siteId); } // Wire the actor with a real EF context against the fixture DB. var services = new ServiceCollection(); services.AddDbContext( opts => opts.UseSqlServer(_fixture.ConnectionString), ServiceLifetime.Scoped); services.AddScoped(); var sp = services.BuildServiceProvider(); var probe = CreateTestProbe(); Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent)); var purgeOptions = new AuditLogPurgeOptions { IntervalHours = 24, IntervalOverride = TimeSpan.FromMilliseconds(100), }; var auditOptions = new AuditLogOptions { RetentionDays = 100 }; CreateActor(sp, purgeOptions, auditOptions); // Wait for the actor's tick to purge the Jan-2026 partition. // Concurrent test runs against the same fixture might also create // eligible partitions, but each test class owns its own fixture DB // (MsSqlMigrationFixture seeds a guid-named DB per class), so the // Jan-2026 boundary is the only one this test can have produced. var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var matched = probe.FishForMessage( isMessage: m => m.MonthBoundary == janBoundary, max: TimeSpan.FromSeconds(30)); Assert.True(matched.RowsDeleted >= 1, $"Expected RowsDeleted >= 1 for Jan-2026 boundary; got {matched.RowsDeleted}."); // Allow a brief settle in case the actor is mid-tick on Feb/Mar // (it shouldn't be, since RetentionDays = 90 means only Jan is // eligible, but the actor MAY re-enumerate quickly while we read). await Task.Delay(TimeSpan.FromMilliseconds(500)); await using var verify = CreateContext(); var rows = await verify.Set() .Where(e => e.SourceSiteId == siteId) .ToListAsync(); // Jan removed; Feb + Mar untouched. Because the test owns the site // id and the fixture DB, exact set membership is observable. Assert.DoesNotContain(rows, r => r.EventId == janEventId); Assert.Contains(rows, r => r.EventId == febEventId); Assert.Contains(rows, r => r.EventId == marEventId); } // --------------------------------------------------------------------- // 2. EndToEnd_UxIndexRebuilt_AfterPurge // --------------------------------------------------------------------- [SkippableFact] public async Task EndToEnd_UxIndexRebuilt_AfterPurge() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Same shape as test 1 — purge the Jan-2026 partition and then // assert the UX_AuditLog_EventId index is still present. The // drop-and-rebuild dance briefly removes it inside its transaction // (the SWITCH PARTITION step requires the non-aligned unique index // to be absent), but step 5 rebuilds it before committing. Sanity- // checking the post-COMMIT shape here documents the invariant in an // assertable way. var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8); var janEventId = Guid.NewGuid(); var (janOccurred, _, _) = SeedOccurredAt(); await using (var seedConn = _fixture.OpenConnection()) { await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId); } var services = new ServiceCollection(); services.AddDbContext( opts => opts.UseSqlServer(_fixture.ConnectionString), ServiceLifetime.Scoped); services.AddScoped(); var sp = services.BuildServiceProvider(); var probe = CreateTestProbe(); Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent)); CreateActor( sp, new AuditLogPurgeOptions { IntervalHours = 24, IntervalOverride = TimeSpan.FromMilliseconds(100), }, new AuditLogOptions { RetentionDays = 90 }); var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); probe.FishForMessage( isMessage: m => m.MonthBoundary == janBoundary, max: TimeSpan.FromSeconds(30)); // Open a fresh connection (the actor's pool is owned by EF) and // assert the index is present post-purge. await using var check = _fixture.OpenConnection(); await AssertUxIndexExistsAsync(check); } // --------------------------------------------------------------------- // 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge // --------------------------------------------------------------------- [SkippableFact] public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Seed + purge a Jan-2026 row, THEN exercise InsertIfNotExistsAsync // twice for a fresh (May-2026) EventId. The second call must be a // no-op (duplicate-key collision swallowed by the repository, per // M2 Bundle A's race-fix) — which means the rebuilt // UX_AuditLog_EventId unique index is functioning as intended. var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8); var janEventId = Guid.NewGuid(); var (janOccurred, _, _) = SeedOccurredAt(); await using (var seedConn = _fixture.OpenConnection()) { await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId); } var services = new ServiceCollection(); services.AddDbContext( opts => opts.UseSqlServer(_fixture.ConnectionString), ServiceLifetime.Scoped); services.AddScoped(); var sp = services.BuildServiceProvider(); var probe = CreateTestProbe(); Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent)); CreateActor( sp, new AuditLogPurgeOptions { IntervalHours = 24, IntervalOverride = TimeSpan.FromMilliseconds(100), }, new AuditLogOptions { RetentionDays = 90 }); var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); probe.FishForMessage( isMessage: m => m.MonthBoundary == janBoundary, max: TimeSpan.FromSeconds(30)); // Settle then exercise InsertIfNotExistsAsync twice for the same // EventId. The repository's idempotency relies on // UX_AuditLog_EventId being present so the IF NOT EXISTS … INSERT // race window resolves to a duplicate-key violation the repo // swallows. If the index were missing here, two rows would land // and the second InsertIfNotExistsAsync would silently double-insert. await Task.Delay(TimeSpan.FromMilliseconds(500)); var freshEventId = Guid.NewGuid(); var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc); var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8); var freshEvt = ScadaBridgeAuditEventFactory.Create( eventId: freshEventId, occurredAtUtc: freshOccurred, channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, sourceSiteId: freshSite, target: "system-x/method"); await using (var ctx = CreateContext()) { var repo = new AuditLogRepository(ctx); await repo.InsertIfNotExistsAsync(freshEvt); // Same row a second time — must be a silent no-op. await repo.InsertIfNotExistsAsync(freshEvt); } await using var verify = CreateContext(); var rows = await verify.Set() .Where(e => e.SourceSiteId == freshSite) .ToListAsync(); Assert.Single(rows); Assert.Equal(freshEventId, rows[0].EventId); } }