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, string channel = "ApiOutbound", string kind = "ApiCall") { 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. // The channel/kind are parameterized so the M5.5 per-channel purge test can // seed multiple channels into the same partition. cmd.CommandText = @" INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson) VALUES (@EventId, @OccurredAtUtc, NULL, @Action, 'Success', @Category, NULL, NULL, NULL, @DetailsJson);"; cmd.Parameters.Add("@Action", System.Data.SqlDbType.VarChar, 64).Value = $"{channel}.{kind}"; cmd.Parameters.Add("@Category", System.Data.SqlDbType.VarChar, 32).Value = channel; 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\":\"" + channel + "\",\"kind\":\"" + kind + "\",\"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))); } /// /// Returns three seed timestamps and a computed RetentionDays value that /// keep the purge-intent date-independent regardless of when the test runs. /// /// /// /// The partition function pf_AuditLog_Month has explicit boundaries only /// for 2026-01-01 through 2027-12-01. Rows outside that range land in the /// catch-all partitions which have no partition_range_values entry and are /// therefore never returned by /// . /// All three seeds must therefore fall inside the defined boundary range. /// /// /// To remain date-independent the test computes RetentionDays dynamically /// so the purge threshold always lands near 2026-01-20: /// /// RetentionDays = (int)(DateTime.UtcNow - new DateTime(2026, 1, 20, UTC)).TotalDays + 1 /// /// This gives: /// /// Jan 15 2026 row → Jan 15 < Jan 20 threshold → PURGED. /// Apr 15 / Jun 15 2026 rows → both after Jan 20 → KEPT. /// /// The threshold anchors to a fixed calendar point (~Jan 20 2026), so the /// relationship holds for any future run date as long as the explicit partition /// boundaries remain. /// /// private static (DateTime Old, DateTime Mid, DateTime Recent, int RetentionDays) SeedOccurredAt() { // Anchor the threshold midway through January 2026 — strictly after the // "old" seed (Jan 15) and strictly before the "mid" seed (Apr 15). var thresholdAnchor = new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc); var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1; return ( Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in Jan-2026 partition → PURGED Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in Apr-2026 partition → KEPT Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in Jun-2026 partition → KEPT RetentionDays: retentionDays ); } // --------------------------------------------------------------------- // 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept // --------------------------------------------------------------------- [SkippableFact] public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); // Seeds three rows in distinct calendar months. RetentionDays is computed // dynamically so the purge threshold always lands near 2026-01-20 (see // SeedOccurredAt() for the full rationale): // Old = Jan 15 2026 → Jan 15 < threshold ~Jan 20 → PURGED // Mid = Apr 15 2026 → Apr 15 > threshold ~Jan 20 → KEPT // Recent = Jun 15 2026 → Jun 15 > threshold ~Jan 20 → KEPT var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8); var oldEventId = Guid.NewGuid(); var midEventId = Guid.NewGuid(); var recentEventId = Guid.NewGuid(); var (oldOccurred, midOccurred, recentOccurred, retentionDays) = SeedOccurredAt(); await using (var seedConn = _fixture.OpenConnection()) { await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId); await DirectInsertAsync(seedConn, midEventId, midOccurred, siteId); await DirectInsertAsync(seedConn, recentEventId, recentOccurred, 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 = retentionDays }; CreateActor(sp, purgeOptions, auditOptions); // The Jan-2026 partition boundary is the only eligible one in this fixture DB. 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 re-enumerates quickly. await Task.Delay(TimeSpan.FromMilliseconds(500)); await using var verify = CreateContext(); var rows = await verify.Set() .Where(e => e.SourceSiteId == siteId) .ToListAsync(); // Old (Jan) removed; Mid (Apr) + Recent (Jun) untouched. Assert.DoesNotContain(rows, r => r.EventId == oldEventId); Assert.Contains(rows, r => r.EventId == midEventId); Assert.Contains(rows, r => r.EventId == recentEventId); } // --------------------------------------------------------------------- // 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. RetentionDays is computed // dynamically so the threshold always lands near 2026-01-20 (see SeedOccurredAt()). // The drop-and-rebuild dance briefly removes the index inside its transaction // (the SWITCH PARTITION step requires the non-aligned unique index to be absent), // but step 5 rebuilds it before committing. var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8); var oldEventId = Guid.NewGuid(); var (oldOccurred, _, _, retentionDays) = SeedOccurredAt(); await using (var seedConn = _fixture.OpenConnection()) { await DirectInsertAsync(seedConn, oldEventId, oldOccurred, 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 = retentionDays }); 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 the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for // a fresh recent 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. // RetentionDays is computed dynamically so the threshold always lands near // 2026-01-20 (see SeedOccurredAt()). var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8); var oldEventId = Guid.NewGuid(); var (oldOccurred, _, _, retentionDays) = SeedOccurredAt(); await using (var seedConn = _fixture.OpenConnection()) { await DirectInsertAsync(seedConn, oldEventId, oldOccurred, 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 = retentionDays }); 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); // within partition range, well inside retention window 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); } // --------------------------------------------------------------------- // 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3) // --------------------------------------------------------------------- /// /// M5.5 (T3): exercises /// directly against the real repository + fixture DB. Seeds, in the SAME partition, /// old + recent rows for an OVERRIDDEN channel (ApiOutbound) and old + recent /// rows for an UN-overridden channel (DbOutbound), then runs the per-channel /// purge for ApiOutbound only. Asserts: /// /// The overridden channel's OLD rows are deleted. /// The overridden channel's RECENT rows (newer than the channel threshold) survive. /// The un-overridden channel's rows (old AND recent) are completely untouched /// — they follow the global window, which the channel purge never applies to them. /// /// This is the maintenance-path row DELETE; the fixture connects as sa, which /// the append-only writer-role DENYs do not bind (the role granularity is exercised /// in the repository/migration tests). /// [SkippableFact] public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = "perchannel-" + Guid.NewGuid().ToString("N").Substring(0, 8); // Two timestamps: one OLD (older than the channel threshold we will purge with) // and one RECENT (newer than it). Both sit comfortably inside the retention // window so the global partition purge would NOT touch either — isolating the // per-channel DELETE as the only force acting here. var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc); var recentOccurred = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc); var apiOldId = Guid.NewGuid(); // ApiOutbound, old → SHOULD be deleted var apiRecentId = Guid.NewGuid(); // ApiOutbound, recent→ SHOULD survive var dbOldId = Guid.NewGuid(); // DbOutbound, old → SHOULD survive (un-overridden) var dbRecentId = Guid.NewGuid(); // DbOutbound, recent → SHOULD survive await using (var seedConn = _fixture.OpenConnection()) { await DirectInsertAsync(seedConn, apiOldId, oldOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall"); await DirectInsertAsync(seedConn, apiRecentId, recentOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall"); await DirectInsertAsync(seedConn, dbOldId, oldOccurred, siteId, channel: "DbOutbound", kind: "DbWrite"); await DirectInsertAsync(seedConn, dbRecentId, recentOccurred, siteId, channel: "DbOutbound", kind: "DbWrite"); } // Purge ApiOutbound rows older than a threshold that sits strictly between the // old (Jan 15) and recent (May 15) seeds — e.g. Mar 1. Only apiOldId qualifies. var channelThreshold = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); await using (var ctx = CreateContext()) { var repo = new AuditLogRepository(ctx); var deleted = await repo.PurgeChannelOlderThanAsync( channel: "ApiOutbound", threshold: channelThreshold, batchSize: 2); Assert.Equal(1L, deleted); // Idempotent: a second run deletes nothing (the eligible row is gone). var deletedAgain = await repo.PurgeChannelOlderThanAsync( channel: "ApiOutbound", threshold: channelThreshold, batchSize: 2); Assert.Equal(0L, deletedAgain); } await using var verify = CreateContext(); var rows = await verify.Set() .Where(e => e.SourceSiteId == siteId) .ToListAsync(); // Overridden channel: old gone, recent kept. Assert.DoesNotContain(rows, r => r.EventId == apiOldId); Assert.Contains(rows, r => r.EventId == apiRecentId); // Un-overridden channel: BOTH rows untouched (follow the global window). Assert.Contains(rows, r => r.EventId == dbOldId); Assert.Contains(rows, r => r.EventId == dbRecentId); } }