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(SourceSiteId: 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(Channel: AuditChannel.Notification, SourceSiteId: 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_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(SourceSiteId: siteId), new AuditLogPaging(PageSize: 10)); Assert.Equal(2, rows.Count); Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId)); } [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( SourceSiteId: 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(SourceSiteId: 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(SourceSiteId: 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(SourceSiteId: 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(SourceSiteId: 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); } 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) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = occurredAtUtc, Channel = channel, Kind = kind, Status = status, SourceSiteId = siteId, ErrorMessage = errorMessage, }; }