using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Tests.Migrations; using Xunit; namespace ScadaLink.ConfigurationDatabase.Tests.Repositories; /// /// Bundle D (#23 M1) integration tests for . Uses /// the same as the Bundle C migration tests so /// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a /// real partitioned schema. Tests scope all queries by a per-test /// SourceSiteId guid suffix so they neither collide with one another nor /// require cleanup. /// public class AuditLogRepositoryTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public AuditLogRepositoryTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } [SkippableFact] public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)); await repo.InsertIfNotExistsAsync(evt); // Re-read in a fresh context so we exercise the persisted row, not the // (already-bypassed) change tracker. await using var readContext = CreateContext(); var loaded = await readContext.Set() .Where(e => e.SourceSiteId == siteId) .ToListAsync(); Assert.Single(loaded); Assert.Equal(evt.EventId, loaded[0].EventId); } [SkippableFact] public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var occurredAt = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc); var first = NewEvent(siteId, occurredAtUtc: occurredAt, errorMessage: "first"); await repo.InsertIfNotExistsAsync(first); // Same EventId, different payload — first-write-wins, the second call is silently a no-op. var second = first with { ErrorMessage = "second-should-be-ignored" }; await repo.InsertIfNotExistsAsync(second); await using var readContext = CreateContext(); var loaded = await readContext.Set() .Where(e => e.SourceSiteId == siteId) .ToListAsync(); Assert.Single(loaded); Assert.Equal("first", loaded[0].ErrorMessage); } [SkippableFact] public async Task QueryAsync_ReturnsRowsInOccurredDescOrder() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(10))); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20))); var rows = await repo.QueryAsync( new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), new AuditLogPaging(PageSize: 10)); Assert.Equal(3, rows.Count); Assert.True(rows[0].OccurredAtUtc > rows[1].OccurredAtUtc); Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc); } [SkippableFact] public async Task QueryAsync_FilterByChannel() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification)); var rows = await repo.QueryAsync( new AuditLogQueryFilter( Channels: new[] { AuditChannel.Notification }, SourceSiteIds: new[] { siteId }), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel)); } [SkippableFact] public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 2, 14, 0, 0, DateTimeKind.Utc); // One row per channel; the multi-value filter must return the union of // ApiOutbound + Notification and exclude DbOutbound. await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.DbOutbound)); var rows = await repo.QueryAsync( new AuditLogQueryFilter( Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, SourceSiteIds: new[] { siteId }), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Contains(r.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification })); Assert.DoesNotContain(rows, r => r.Channel == AuditChannel.DbOutbound); } [SkippableFact] public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 2, 15, 0, 0, DateTimeKind.Utc); // Failed + Parked are requested; Delivered must be excluded. await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, status: AuditStatus.Failed)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), status: AuditStatus.Parked)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), status: AuditStatus.Delivered)); var rows = await repo.QueryAsync( new AuditLogQueryFilter( Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked }, SourceSiteIds: new[] { siteId }), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Contains(r.Status, new[] { AuditStatus.Failed, AuditStatus.Parked })); Assert.DoesNotContain(rows, r => r.Status == AuditStatus.Delivered); } [SkippableFact] public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteA = NewSiteId(); var siteB = NewSiteId(); var siteC = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 2, 16, 0, 0, DateTimeKind.Utc); await repo.InsertIfNotExistsAsync(NewEvent(siteA, occurredAtUtc: t0)); await repo.InsertIfNotExistsAsync(NewEvent(siteB, occurredAtUtc: t0.AddMinutes(1))); await repo.InsertIfNotExistsAsync(NewEvent(siteC, occurredAtUtc: t0.AddMinutes(2))); var rows = await repo.QueryAsync( new AuditLogQueryFilter(SourceSiteIds: new[] { siteA, siteB }), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Contains(r.SourceSiteId, new[] { siteA, siteB })); Assert.DoesNotContain(rows, r => r.SourceSiteId == siteC); } [SkippableFact] public async Task QueryAsync_EmptyChannelList_DoesNotConstrain() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 2, 17, 0, 0, DateTimeKind.Utc); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification)); // An empty Channels list must mean "no filter" — NOT WHERE 1=0. var rows = await repo.QueryAsync( new AuditLogQueryFilter( Channels: Array.Empty(), SourceSiteIds: new[] { siteId }), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); } [SkippableFact] public async Task QueryAsync_FilterBySourceSiteId() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); var otherSiteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1))); await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2))); var rows = await repo.QueryAsync( new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId)); } [SkippableFact] public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var executionId = Guid.NewGuid(); var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc); // Two rows share the ExecutionId; one carries a different ExecutionId and // one leaves it null — both must be excluded by the single-value filter. await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid())); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null)); var rows = await repo.QueryAsync( new AuditLogQueryFilter( SourceSiteIds: new[] { siteId }, ExecutionId: executionId), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId)); } [SkippableFact] public async Task QueryAsync_FilterByTimeRange() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 4, 9, 0, 0, DateTimeKind.Utc); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(30))); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddHours(2))); var rows = await repo.QueryAsync( new AuditLogQueryFilter( SourceSiteIds: new[] { siteId }, FromUtc: t0.AddMinutes(10), ToUtc: t0.AddHours(1)), new AuditLogPaging(PageSize: 10)); Assert.Single(rows); Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc); } [SkippableFact] public async Task QueryAsync_Keyset_NextPageStartsAfterCursor() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); var t0 = new DateTime(2026, 5, 5, 9, 0, 0, DateTimeKind.Utc); // Five rows at one-minute intervals. Page-size 2 → page 1 returns minutes 4,3. // Cursor (minutes 3) → page 2 returns minutes 2,1. Cursor (minutes 1) → page 3 returns minute 0. for (var i = 0; i < 5; i++) { await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(i))); } var page1 = await repo.QueryAsync( new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), new AuditLogPaging(PageSize: 2)); Assert.Equal(2, page1.Count); Assert.Equal(t0.AddMinutes(4), page1[0].OccurredAtUtc); Assert.Equal(t0.AddMinutes(3), page1[1].OccurredAtUtc); var cursor = page1[^1]; var page2 = await repo.QueryAsync( new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), new AuditLogPaging( PageSize: 2, AfterOccurredAtUtc: cursor.OccurredAtUtc, AfterEventId: cursor.EventId)); Assert.Equal(2, page2.Count); Assert.Equal(t0.AddMinutes(2), page2[0].OccurredAtUtc); Assert.Equal(t0.AddMinutes(1), page2[1].OccurredAtUtc); var cursor2 = page2[^1]; var page3 = await repo.QueryAsync( new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), new AuditLogPaging( PageSize: 2, AfterOccurredAtUtc: cursor2.OccurredAtUtc, AfterEventId: cursor2.EventId)); Assert.Single(page3); Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc); } [SkippableFact] public async Task InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); // Single event used by every parallel call — same EventId, same payload. // The repository's IF NOT EXISTS … INSERT pattern has a check-then-act // race window between sessions; under concurrent load SQL Server can // raise a unique-index violation (error 2601) on UX_AuditLog_EventId. // Bundle A's hardening swallows 2601/2627 so duplicates collapse silently. var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)); // 50 parallel inserters, each with its own DbContext (DbContext is not // thread-safe). Parallel.ForEachAsync aggregates exceptions, so a single // unhandled 2601 from the repository would fail this test loudly. await Parallel.ForEachAsync( Enumerable.Range(0, 50), new ParallelOptions { MaxDegreeOfParallelism = 50 }, async (_, ct) => { await using var context = CreateContext(); var repo = new AuditLogRepository(context); await repo.InsertIfNotExistsAsync(evt, ct); }); await using var readContext = CreateContext(); var count = await readContext.Set() .Where(e => e.SourceSiteId == siteId) .CountAsync(); Assert.Equal(1, count); } [SkippableFact] public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Four events all sharing the exact same OccurredAtUtc — the keyset // cursor must lean on the EventId tiebreaker (descending) to page // deterministically. Bundle D's reviewer flagged this as a deferred // verification because it depends on EF Core 10 translating // Guid.CompareTo against SQL Server's uniqueidentifier sort order. var occurredAt = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc); // Build four distinct Guids; we don't care about the literal ordering // produced by Guid.CompareTo — only that paging is deterministic and // covers every row exactly once. var events = Enumerable.Range(0, 4) .Select(_ => NewEvent(siteId, occurredAtUtc: occurredAt)) .ToList(); foreach (var e in events) { await repo.InsertIfNotExistsAsync(e); } var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }); var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2)); Assert.Equal(2, page1.Count); Assert.All(page1, r => Assert.Equal(occurredAt, r.OccurredAtUtc)); var cursor = page1[^1]; var page2 = await repo.QueryAsync( filter, new AuditLogPaging( PageSize: 2, AfterOccurredAtUtc: cursor.OccurredAtUtc, AfterEventId: cursor.EventId)); Assert.Equal(2, page2.Count); Assert.All(page2, r => Assert.Equal(occurredAt, r.OccurredAtUtc)); var page1Ids = page1.Select(r => r.EventId).ToHashSet(); var page2Ids = page2.Select(r => r.EventId).ToHashSet(); // No overlap between pages. Assert.Empty(page1Ids.Intersect(page2Ids)); // Every inserted EventId appears in exactly one of the two pages. var allIds = page1Ids.Union(page2Ids).ToHashSet(); Assert.Equal(4, allIds.Count); Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(allIds)); } // ------------------------------------------------------------------------ // M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests // ------------------------------------------------------------------------ // // The partition-switch path replaces M1's NotSupportedException stub with // the production drop-DROP-INDEX → CREATE-staging → SWITCH PARTITION → // DROP-staging → CREATE-INDEX dance documented in alog.md §4. These tests // verify the side effects an outsider can observe: // * rows in the targeted month are removed // * rows in OTHER months are NOT touched // * UX_AuditLog_EventId still exists after a successful switch // * InsertIfNotExistsAsync's first-write-wins idempotency still holds // after a switch (the rebuilt index is real) // * a thrown SqlException leaves UX_AuditLog_EventId rebuilt (the CATCH // branch's recovery path runs) [SkippableFact] public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Three distinct months — Jan, Feb, Mar 2026 — so the switch on Jan's // boundary purges exactly one month's worth of rows. Boundary values // come from the partition function's pre-seeded list (alog.md §4). var janEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc)); var febEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 2, 15, 10, 0, 0, DateTimeKind.Utc)); var marEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc)); await repo.InsertIfNotExistsAsync(janEvt); await repo.InsertIfNotExistsAsync(febEvt); await repo.InsertIfNotExistsAsync(marEvt); // Boundary value '2026-01-01' identifies the January 2026 partition under // RANGE RIGHT semantics ($PARTITION returns the partition into which the // boundary value itself falls — the partition whose lower bound is the // boundary). await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); await using var readContext = CreateContext(); var remaining = await readContext.Set() .Where(e => e.SourceSiteId == siteId) .ToListAsync(); Assert.DoesNotContain(remaining, e => e.EventId == janEvt.EventId); Assert.Contains(remaining, e => e.EventId == febEvt.EventId); Assert.Contains(remaining, e => e.EventId == marEvt.EventId); } [SkippableFact] public async Task SwitchOutPartitionAsync_RebuildsUxIndex_AfterSwitch() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Pick a different month per test so successive test runs (which share // the fixture's MSSQL database) don't tread on each other. await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); await using var verifyContext = CreateContext(); var indexExists = await ScalarAsync( verifyContext, "SELECT COUNT(*) FROM sys.indexes " + "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');"); Assert.Equal(1, indexExists); } [SkippableFact] public async Task SwitchOutPartitionAsync_InsertIfNotExistsAsync_StillEnforcesFirstWriteWins_AfterSwitch() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Pre-existing row in May 2026 — must survive a switch on a different // (older) partition. var preExisting = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc)); await repo.InsertIfNotExistsAsync(preExisting); // Switch out the June 2026 partition (different month, empty). await repo.SwitchOutPartitionAsync(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)); // Re-attempting the same EventId after the switch must STILL be a no-op // (UX_AuditLog_EventId is the index that enables idempotency; if the // rebuild left it broken, this insert would silently produce a duplicate // row and the count assertion below would catch it). var dup = preExisting with { ErrorMessage = "second-should-be-ignored-after-switch" }; await repo.InsertIfNotExistsAsync(dup); await using var readContext = CreateContext(); var rows = await readContext.Set() .Where(e => e.SourceSiteId == siteId) .ToListAsync(); Assert.Single(rows); Assert.Equal(preExisting.EventId, rows[0].EventId); // First-write-wins: the original ErrorMessage (null) survives. Assert.Null(rows[0].ErrorMessage); } [SkippableFact] public async Task SwitchOutPartitionAsync_PartialFailure_RebuildsUxIndex_RaisesException() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Force a deterministic switch failure with an inbound FOREIGN KEY: // ALTER TABLE … SWITCH refuses to move rows out of a partition that's // referenced by an FK from another table, raising msg 4928 // ("ALTER TABLE SWITCH statement failed because target table … has a // foreign key …"). The CATCH branch then rolls back and rebuilds the // unique index — which the assertion below verifies. // // The probe table is uniquely named with a guid suffix so reruns of // this test inside the same fixture DB never collide. We clean it up // in the finally so the constraint never leaks into other tests. var probeTable = $"AuditFkProbe_{Guid.NewGuid():N}".Substring(0, 32); await using (var setup = new SqlConnection(_fixture.ConnectionString)) { await setup.OpenAsync(); await using var cmd = setup.CreateCommand(); // Composite FK references AuditLog's composite PK (EventId, OccurredAtUtc). cmd.CommandText = $"CREATE TABLE dbo.[{probeTable}] ( " + $" EventId uniqueidentifier NOT NULL, " + $" OccurredAtUtc datetime2(7) NOT NULL, " + $" CONSTRAINT FK_{probeTable}_AuditLog FOREIGN KEY (EventId, OccurredAtUtc) " + $" REFERENCES dbo.AuditLog(EventId, OccurredAtUtc));"; await cmd.ExecuteNonQueryAsync(); } try { var ex = await Assert.ThrowsAnyAsync( () => repo.SwitchOutPartitionAsync(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc))); // Smoke-check the message references the SWITCH statement so we // know we hit the engineered failure, not some unrelated error. Assert.Contains("SWITCH", ex.Message, StringComparison.OrdinalIgnoreCase); } finally { // Always drop the probe table so the FK is gone before the next // test runs against the shared fixture. await using var cleanup = new SqlConnection(_fixture.ConnectionString); await cleanup.OpenAsync(); await using var cmd = cleanup.CreateCommand(); cmd.CommandText = $"IF OBJECT_ID('dbo.[{probeTable}]', 'U') IS NOT NULL DROP TABLE dbo.[{probeTable}];"; await cmd.ExecuteNonQueryAsync(); } // The CATCH block in the production SQL guarantees UX_AuditLog_EventId // is rebuilt regardless of which step failed inside the TRY. await using var verifyContext = CreateContext(); var indexExists = await ScalarAsync( verifyContext, "SELECT COUNT(*) FROM sys.indexes " + "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');"); Assert.Equal(1, indexExists); } // ------------------------------------------------------------------------ // M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync // ------------------------------------------------------------------------ [SkippableFact] public async Task GetPartitionBoundariesOlderThanAsync_ReturnsBoundaries_WithMaxOccurredOlderThanThreshold() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Seed events in two months: July 2026 (old) and August 2026 (new). await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 7, 10, 0, 0, 0, DateTimeKind.Utc))); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc))); // Threshold = Aug 1 2026 — July partition's MAX (July 10) is older; // August partition's MAX (August 10) is newer. We expect only the July // boundary back. var threshold = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); var boundaries = await repo.GetPartitionBoundariesOlderThanAsync(threshold); // The repo may also return EARLIER boundaries that have no data (their // MAX is NULL → treated as "no data, nothing to purge" by the contract). // We only assert the inclusion/exclusion that matters for our seeded // rows. Assert.Contains(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), boundaries); Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries); } // ------------------------------------------------------------------------ // M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles // ------------------------------------------------------------------------ // // The dashboard's "Audit volume" tile reads TotalEventsLastHour and the // "Audit error rate" tile reads ErrorEventsLastHour / TotalEventsLastHour. // The repository must (a) count rows whose OccurredAtUtc falls in // [nowUtc - window, nowUtc] and (b) within that scope count rows whose // Status ∈ {Failed, Parked, Discarded} as "error". BacklogTotal is left at // zero here — the service layer composes it in from the health aggregator. // // To keep the test deterministic against the shared fixture DB, each test // pins an obscure-distant nowUtc and seeds rows with OccurredAtUtc inside a // narrow band centred on that anchor — no other test in this class seeds // there, so the global count equals the seeded count for that band. [SkippableFact] public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var siteId = NewSiteId(); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Anchor in November 2026 — no other test in this class seeds there. var nowUtc = new DateTime(2026, 11, 20, 10, 0, 0, DateTimeKind.Utc); // Seed 3 success + 1 Failed + 1 Parked + 1 Discarded inside the trailing // 1h window; plus 1 row outside the window that must be excluded. await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-5), status: AuditStatus.Delivered)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-10), status: AuditStatus.Delivered)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-15), status: AuditStatus.Delivered)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-20), status: AuditStatus.Failed)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-25), status: AuditStatus.Parked)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-30), status: AuditStatus.Discarded)); // Outside-window row (2h before nowUtc). await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddHours(-2), status: AuditStatus.Failed)); // Submitted is in-flight, not an "error" — must NOT count toward errors. await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-2), status: AuditStatus.Submitted)); var snapshot = await repo.GetKpiSnapshotAsync( window: TimeSpan.FromHours(1), nowUtc: nowUtc); // 7 rows fall in the trailing 1h window (3 Delivered + 1 Failed + 1 Parked + 1 Discarded + 1 Submitted). // The 2h-before-nowUtc Failed row is excluded by the window. Assert.Equal(7, snapshot.TotalEventsLastHour); // Only Failed/Parked/Discarded count as errors → 3. Assert.Equal(3, snapshot.ErrorEventsLastHour); // The service layer fills BacklogTotal; the repo leaves it at 0. Assert.Equal(0, snapshot.BacklogTotal); // AsOfUtc echoes the anchor. Assert.Equal(nowUtc, snapshot.AsOfUtc); } [SkippableFact] public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // Anchor in December 2026 — no test seeds there, so the window is empty. var nowUtc = new DateTime(2026, 12, 20, 10, 0, 0, DateTimeKind.Utc); var snapshot = await repo.GetKpiSnapshotAsync( window: TimeSpan.FromMinutes(1), nowUtc: nowUtc); Assert.Equal(0, snapshot.TotalEventsLastHour); Assert.Equal(0, snapshot.ErrorEventsLastHour); Assert.Equal(0, snapshot.BacklogTotal); Assert.Equal(nowUtc, snapshot.AsOfUtc); } private async Task ScalarAsync(ScadaLinkDbContext context, string sql) { var conn = context.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) { await conn.OpenAsync(); } await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; var result = await cmd.ExecuteScalarAsync(); if (result is null || result is DBNull) { return default!; } return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; } // --- helpers ------------------------------------------------------------ private ScadaLinkDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString) .Options; return new ScadaLinkDbContext(options); } private static string NewSiteId() => "test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8); private static AuditEvent NewEvent( string siteId, DateTime occurredAtUtc, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, AuditStatus status = AuditStatus.Delivered, string? errorMessage = null, Guid? executionId = null) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = occurredAtUtc, Channel = channel, Kind = kind, Status = status, SourceSiteId = siteId, ErrorMessage = errorMessage, ExecutionId = executionId, }; }