Two concurrent sessions can both pass the IF NOT EXISTS check and then both attempt the INSERT against UX_AuditLog_EventId; the loser surfaced as SqlException 2601 (or 2627 for PK violations) and aborted the audit write. First-write-wins idempotency is the documented contract, so the race outcome is semantically a no-op — catch the two duplicate-key error numbers and log at Debug, let any other SqlException bubble. Tests: - InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow: 50 parallel inserters with the same EventId end with exactly one row and no escaped exceptions. - QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId: four rows sharing the same OccurredAtUtc page deterministically through the (OccurredAtUtc, EventId) keyset cursor — exercises the e.OccurredAtUtc == after && e.EventId.CompareTo(afterId) < 0 branch and verifies EF Core 10's Guid.CompareTo translation against SQL Server uniqueidentifier order (deferred Bundle D reviewer recommendation). AuditLogRepository now takes an optional ILogger<AuditLogRepository> (NullLogger default, mirrors InboundApiRepository); DI registration unchanged.
360 lines
14 KiB
C#
360 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Bundle D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses
|
|
/// the same <see cref="MsSqlMigrationFixture"/> 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
|
|
/// <c>SourceSiteId</c> guid suffix so they neither collide with one another nor
|
|
/// require cleanup.
|
|
/// </summary>
|
|
public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|
{
|
|
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<AuditEvent>()
|
|
.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<AuditEvent>()
|
|
.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<AuditEvent>()
|
|
.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<NotSupportedException>(
|
|
() => 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<ScadaLinkDbContext>()
|
|
.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,
|
|
};
|
|
}
|