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)); } [SkippableFact] public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var context = CreateContext(); var repo = new AuditLogRepository(context); // The partition-switch path is intentionally blocked in M1 because // UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships // with the M6 purge actor. var ex = await Assert.ThrowsAsync( () => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc))); Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase); } // --- 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, }; }