feat(configdb): AuditLogRepository (EF) + DI registration, append-only with M6-deferred SwitchOutPartition (#23)
EF Core implementation of IAuditLogRepository: - InsertIfNotExistsAsync: single IF NOT EXISTS ... INSERT via ExecuteSqlInterpolatedAsync, bypasses the change tracker. Enum values converted to string in C# (columns are varchar(32) via HasConversion<string>). - QueryAsync: AsNoTracking, predicate-per-non-null-filter, keyset paging on (OccurredAtUtc DESC, EventId DESC) — EF Core 10 translates Guid.CompareTo to a uniqueidentifier < comparison natively (verified against MSSQL 2022). - SwitchOutPartitionAsync: throws NotSupportedException naming M6; the non-aligned UX_AuditLog_EventId unique index blocks ALTER TABLE SWITCH PARTITION until the drop-and-rebuild dance ships with the purge actor. DI: AddScoped<IAuditLogRepository, AuditLogRepository>() added after the NotificationOutboxRepository registration; existing DI smoke test extended with an IAuditLogRepository assertion. Integration tests (8 new) use the Bundle C MsSqlMigrationFixture and scope by a per-test SourceSiteId guid so they neither collide nor require cleanup. Bundle D of the Audit Log #23 M1 Foundation plan.
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
|
||||||
|
/// for the append-only contract; this class only adds notes on the data-access
|
||||||
|
/// strategy used by each method.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogRepository : IAuditLogRepository
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
|
||||||
|
public AuditLogRepository(ScadaLinkDbContext context)
|
||||||
|
{
|
||||||
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a single <c>IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)</c>
|
||||||
|
/// via <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>.
|
||||||
|
/// Bypasses the EF change tracker so the row never enters a tracked state and
|
||||||
|
/// the enum-as-string conversion is done explicitly in C# (the columns are
|
||||||
|
/// declared <c>varchar(32)</c> via <c>HasConversion<string>()</c> in
|
||||||
|
/// <see cref="ScadaLink.ConfigurationDatabase.Configurations.AuditLogEntityTypeConfiguration"/>).
|
||||||
|
/// </summary>
|
||||||
|
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (evt is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(evt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
|
||||||
|
// the conversion in C# rather than relying on parameter type inference —
|
||||||
|
// SqlClient would otherwise bind enums as int by default.
|
||||||
|
var channel = evt.Channel.ToString();
|
||||||
|
var kind = evt.Kind.ToString();
|
||||||
|
var status = evt.Status.ToString();
|
||||||
|
var forwardState = evt.ForwardState?.ToString();
|
||||||
|
|
||||||
|
// FormattableString interpolation parameterises every value (no concatenation),
|
||||||
|
// so this is safe against injection even for the string columns.
|
||||||
|
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
||||||
|
INSERT INTO dbo.AuditLog
|
||||||
|
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
|
||||||
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
|
||||||
|
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
||||||
|
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
||||||
|
VALUES
|
||||||
|
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
|
||||||
|
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
||||||
|
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
||||||
|
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <c>AsNoTracking</c> queryable over <see cref="AuditEvent"/>, applies
|
||||||
|
/// every non-null filter predicate, and pages by keyset on
|
||||||
|
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>. The keyset clause is expressed
|
||||||
|
/// directly (<c>occurred < after || (occurred == after && eventId.CompareTo(afterId) < 0)</c>)
|
||||||
|
/// — EF Core 10 translates <see cref="Guid.CompareTo(Guid)"/> against SQL Server's
|
||||||
|
/// <c>uniqueidentifier</c> sort order.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (filter is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paging is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(paging));
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = _context.Set<AuditEvent>().AsNoTracking();
|
||||||
|
|
||||||
|
if (filter.Channel is { } channel)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.Channel == channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.Kind is { } kind)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.Kind == kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.Status is { } status)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.SourceSiteId))
|
||||||
|
{
|
||||||
|
var siteId = filter.SourceSiteId;
|
||||||
|
query = query.Where(e => e.SourceSiteId == siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.Target))
|
||||||
|
{
|
||||||
|
var target = filter.Target;
|
||||||
|
query = query.Where(e => e.Target == target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.Actor))
|
||||||
|
{
|
||||||
|
var actor = filter.Actor;
|
||||||
|
query = query.Where(e => e.Actor == actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.CorrelationId is { } correlationId)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.CorrelationId == correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.FromUtc is { } fromUtc)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ToUtc is { } toUtc)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.OccurredAtUtc <= toUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyset cursor on (OccurredAtUtc desc, EventId desc).
|
||||||
|
if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
|
||||||
|
{
|
||||||
|
query = query.Where(e =>
|
||||||
|
e.OccurredAtUtc < afterOccurred
|
||||||
|
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(e => e.OccurredAtUtc)
|
||||||
|
.ThenByDescending(e => e.EventId)
|
||||||
|
.Take(paging.PageSize)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1 honest contract: throws <see cref="NotSupportedException"/>. The
|
||||||
|
/// <c>UX_AuditLog_EventId</c> unique index is non-aligned with
|
||||||
|
/// <c>ps_AuditLog_Month</c> (it lives on <c>[PRIMARY]</c> to keep
|
||||||
|
/// <see cref="InsertIfNotExistsAsync"/> cheap), and SQL Server rejects
|
||||||
|
/// <c>ALTER TABLE … SWITCH PARTITION</c> when a non-aligned index is present.
|
||||||
|
/// The drop-and-rebuild dance that makes the switch legal ships with the M6
|
||||||
|
/// purge actor.
|
||||||
|
/// </summary>
|
||||||
|
public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " +
|
||||||
|
"unique index; the drop-and-rebuild dance ships in M6 (purge actor).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
||||||
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||||
services.AddScoped<IAuditService, AuditService>();
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public class ServiceCollectionExtensionsTests
|
|||||||
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
|
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
|
||||||
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
|
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
|
||||||
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
|
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
|
||||||
|
Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly
|
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly
|
||||||
|
|||||||
Reference in New Issue
Block a user