feat(auditlog): multi-value AuditLogQueryFilter dimensions

This commit is contained in:
Joseph Doherty
2026-05-21 05:15:51 -04:00
parent b3b02a8cb6
commit 37c7a0e5ac
15 changed files with 196 additions and 77 deletions

View File

@@ -116,10 +116,10 @@ public static class AuditExportEndpoints
DateTime? toUtc = ParseUtcDate(query, "to"); DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channel: channel, Channels: channel is { } c ? new[] { c } : null,
Kind: kind, Kinds: kind is { } k ? new[] { k } : null,
Status: status, Statuses: status is { } s ? new[] { s } : null,
SourceSiteId: site, SourceSiteIds: site is { } siteId ? new[] { siteId } : null,
Target: target, Target: target,
Actor: actor, Actor: actor,
CorrelationId: correlationId, CorrelationId: correlationId,

View File

@@ -114,10 +114,10 @@ public sealed class AuditQueryModel
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow); var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channel: Channels.Count > 0 ? Channels.First() : null, Channels: Channels.Count > 0 ? new[] { Channels.First() } : null,
Kind: Kinds.Count > 0 ? Kinds.First() : null, Kinds: Kinds.Count > 0 ? new[] { Kinds.First() } : null,
Status: status, Statuses: status is { } s ? new[] { s } : null,
SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null, SourceSiteIds: SiteIdentifiers.Count > 0 ? new[] { SiteIdentifiers.First() } : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(), Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(), Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null, CorrelationId: null,

View File

@@ -129,9 +129,9 @@ public partial class AuditLogPage
} }
_currentFilter = new AuditLogQueryFilter( _currentFilter = new AuditLogQueryFilter(
Channel: channel, Channels: channel is { } ch ? new[] { ch } : null,
Status: status, Statuses: status is { } st ? new[] { st } : null,
SourceSiteId: site, SourceSiteIds: site is { } siteId ? new[] { siteId } : null,
Target: target, Target: target,
Actor: actor, Actor: actor,
CorrelationId: correlationId); CorrelationId: correlationId);
@@ -181,21 +181,24 @@ public partial class AuditLogPage
} }
var parts = new List<KeyValuePair<string, string?>>(9); var parts = new List<KeyValuePair<string, string?>>(9);
if (filter.Channel is { } ch) // Task 8: the filter dimensions are multi-value now; the export query-string
// is still single-value, so emit the first selected value of each list.
// Task 9 widens the query-string contract to carry the full set.
if (filter.Channels is { Count: > 0 } channels)
{ {
parts.Add(new("channel", ch.ToString())); parts.Add(new("channel", channels[0].ToString()));
} }
if (filter.Kind is { } kind) if (filter.Kinds is { Count: > 0 } kinds)
{ {
parts.Add(new("kind", kind.ToString())); parts.Add(new("kind", kinds[0].ToString()));
} }
if (filter.Status is { } status) if (filter.Statuses is { Count: > 0 } statuses)
{ {
parts.Add(new("status", status.ToString())); parts.Add(new("status", statuses[0].ToString()));
} }
if (!string.IsNullOrWhiteSpace(filter.SourceSiteId)) if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds && !string.IsNullOrWhiteSpace(sourceSiteIds[0]))
{ {
parts.Add(new("site", filter.SourceSiteId)); parts.Add(new("site", sourceSiteIds[0]));
} }
if (!string.IsNullOrWhiteSpace(filter.Target)) if (!string.IsNullOrWhiteSpace(filter.Target))
{ {

View File

@@ -4,16 +4,20 @@ namespace ScadaLink.Commons.Types.Audit;
/// <summary> /// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>. /// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds /// Any field left <c>null</c> means "do not constrain on that column". The
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and /// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses /// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
/// <c>&gt;=</c> / <c>&lt;=</c> respectively. All filter fields are AND-combined. /// list means "do not constrain", and a non-empty list is OR-combined within the
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another.
/// </summary> /// </summary>
public sealed record AuditLogQueryFilter( public sealed record AuditLogQueryFilter(
AuditChannel? Channel = null, IReadOnlyList<AuditChannel>? Channels = null,
AuditKind? Kind = null, IReadOnlyList<AuditKind>? Kinds = null,
AuditStatus? Status = null, IReadOnlyList<AuditStatus>? Statuses = null,
string? SourceSiteId = null, IReadOnlyList<string>? SourceSiteIds = null,
string? Target = null, string? Target = null,
string? Actor = null, string? Actor = null,
Guid? CorrelationId = null, Guid? CorrelationId = null,

View File

@@ -116,25 +116,28 @@ VALUES
var query = _context.Set<AuditEvent>().AsNoTracking(); var query = _context.Set<AuditEvent>().AsNoTracking();
if (filter.Channel is { } channel) // Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
if (filter.Channels is { Count: > 0 } channels)
{ {
query = query.Where(e => e.Channel == channel); query = query.Where(e => channels.Contains(e.Channel));
} }
if (filter.Kind is { } kind) if (filter.Kinds is { Count: > 0 } kinds)
{ {
query = query.Where(e => e.Kind == kind); query = query.Where(e => kinds.Contains(e.Kind));
} }
if (filter.Status is { } status) if (filter.Statuses is { Count: > 0 } statuses)
{ {
query = query.Where(e => e.Status == status); query = query.Where(e => statuses.Contains(e.Status));
} }
if (!string.IsNullOrEmpty(filter.SourceSiteId)) if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{ {
var siteId = filter.SourceSiteId; query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
query = query.Where(e => e.SourceSiteId == siteId);
} }
if (!string.IsNullOrEmpty(filter.Target)) if (!string.IsNullOrEmpty(filter.Target))

View File

@@ -401,11 +401,13 @@ public static class AuditEndpoints
correlationId = parsedCorr; correlationId = parsedCorr;
} }
var sourceSiteId = TrimToNullable(query, "sourceSiteId");
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channel: channel, Channels: channel is { } c ? new[] { c } : null,
Kind: kind, Kinds: kind is { } k ? new[] { k } : null,
Status: status, Statuses: status is { } s ? new[] { s } : null,
SourceSiteId: TrimToNullable(query, "sourceSiteId"), SourceSiteIds: sourceSiteId is { } site ? new[] { site } : null,
Target: TrimToNullable(query, "target"), Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"), Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId, CorrelationId: correlationId,

View File

@@ -214,7 +214,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext(); await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext); var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync( var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows); var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel); Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
@@ -282,7 +282,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext(); await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext); var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync( var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows); var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel); Assert.Equal(AuditChannel.DbOutbound, evt.Channel);

View File

@@ -241,7 +241,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext(); await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx); var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync( var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50)); new AuditLogPaging(PageSize: 50));
// 1 Submit + 1 Attempted = 2 rows so far. // 1 Submit + 1 Attempted = 2 rows so far.
Assert.Equal(2, rows.Count); Assert.Equal(2, rows.Count);
@@ -257,7 +257,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext(); await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx); var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync( var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50)); new AuditLogPaging(PageSize: 50));
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows. // 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
Assert.InRange(rows.Count, 3, 4); Assert.InRange(rows.Count, 3, 4);

View File

@@ -160,7 +160,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext(); await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext); var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync( var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
Assert.Single(rows); Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId); Assert.Equal(evt.EventId, rows[0].EventId);
@@ -207,7 +207,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext(); await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext); var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync( var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
Assert.Single(rows); Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId); Assert.Equal(evt.EventId, rows[0].EventId);
@@ -260,7 +260,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext(); await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext); var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync( var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
Assert.Single(rows); Assert.Single(rows);
Assert.Equal(sharedId, rows[0].EventId); Assert.Equal(sharedId, rows[0].EventId);

View File

@@ -160,10 +160,10 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync( await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == AuditChannel.ApiOutbound && f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound &&
f.Kind == AuditKind.ApiCall && f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall &&
f.Status == AuditStatus.Failed && f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed &&
f.SourceSiteId == "plant-a" && f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" &&
f.Target == "PaymentApi" && f.Target == "PaymentApi" &&
f.Actor == "apikey-1" && f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) && f.CorrelationId == Guid.Parse(correlationId) &&
@@ -188,10 +188,10 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync( await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == null && f.Channels == null &&
f.Kind == null && f.Kinds == null &&
f.Status == null && f.Statuses == null &&
f.SourceSiteId == null && f.SourceSiteIds == null &&
f.Target == null && f.Target == null &&
f.Actor == null && f.Actor == null &&
f.CorrelationId == null && f.CorrelationId == null &&
@@ -216,7 +216,7 @@ public class AuditExportEndpointsTests
_ = await response.Content.ReadAsStringAsync(); _ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync( await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Channel == null), Arg.Is<AuditLogQueryFilter>(f => f.Channels == null),
Arg.Any<AuditLogPaging>(), Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }

View File

@@ -77,7 +77,9 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured); Assert.NotNull(captured);
Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel); // Task 8: the filter dimension is multi-value now; ToFilter still collapses
// the chip selection to a single-element list (Task 9 widens that).
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
Assert.Equal("Plant-A-OPC", captured.Target); Assert.Equal("Plant-A-OPC", captured.Target);
} }
@@ -117,14 +119,14 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured); Assert.NotNull(captured);
// Single-value filter contract: Failed leads the non-success set. // Single-value collapse contract (Task 8): Failed leads the non-success set.
Assert.Equal(AuditStatus.Failed, captured!.Status); Assert.Equal(new[] { AuditStatus.Failed }, captured!.Statuses);
// Now pin an explicit Status chip — Errors-only must yield (chip wins). // Now pin an explicit Status chip — Errors-only must yield (chip wins).
cut.Find("[data-test=\"chip-status-Delivered\"]").Click(); cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
cut.Find("[data-test=\"filter-apply\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(AuditStatus.Delivered, captured!.Status); Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
} }
[Fact] [Fact]

View File

@@ -36,10 +36,10 @@ public class AuditLogPageExportUrlTests
{ {
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var filter = new AuditLogQueryFilter( var filter = new AuditLogQueryFilter(
Channel: AuditChannel.ApiOutbound, Channels: new[] { AuditChannel.ApiOutbound },
Kind: AuditKind.ApiCall, Kinds: new[] { AuditKind.ApiCall },
Status: AuditStatus.Failed, Statuses: new[] { AuditStatus.Failed },
SourceSiteId: "plant-a", SourceSiteIds: new[] { "plant-a" },
Target: "PaymentApi", Target: "PaymentApi",
Actor: "apikey-1", Actor: "apikey-1",
CorrelationId: corr, CorrelationId: corr,
@@ -65,7 +65,7 @@ public class AuditLogPageExportUrlTests
[Fact] [Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams() public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{ {
var filter = new AuditLogQueryFilter(Channel: AuditChannel.Notification); var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.Notification });
var url = AuditLogPage.BuildExportUrl(filter); var url = AuditLogPage.BuildExportUrl(filter);

View File

@@ -197,7 +197,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
_queryService.Received().QueryAsync( _queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.SourceSiteId == "plant-a"), Arg.Is<AuditLogQueryFilter>(f =>
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"),
Arg.Any<AuditLogPaging?>(), Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
}); });
@@ -218,7 +219,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
_queryService.Received().QueryAsync( _queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed), Arg.Is<AuditLogQueryFilter>(f =>
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed),
Arg.Any<AuditLogPaging?>(), Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
}); });

View File

@@ -34,7 +34,7 @@ public class AuditLogQueryServiceTests
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository() public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{ {
var repo = Substitute.For<IAuditLogRepository>(); var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound); var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25); var paging = new AuditLogPaging(PageSize: 25);
var expected = new List<AuditEvent> var expected = new List<AuditEvent>
{ {
@@ -179,7 +179,7 @@ public class AuditLogQueryServiceTests
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>(); var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator()); var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound); var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a // Fire two QueryAsync calls in parallel. With scope-per-query each gets a
// fresh DbContext, so this completes cleanly; with a shared scoped context // fresh DbContext, so this completes cleanly; with a shared scoped context

View File

@@ -91,7 +91,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20))); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
var rows = await repo.QueryAsync( var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
Assert.Equal(3, rows.Count); Assert.Equal(3, rows.Count);
@@ -114,13 +114,116 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification)); await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
var rows = await repo.QueryAsync( var rows = await repo.QueryAsync(
new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId), new AuditLogQueryFilter(
Channels: new[] { AuditChannel.Notification },
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count); Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel)); 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<AuditChannel>(),
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
}
[SkippableFact] [SkippableFact]
public async Task QueryAsync_FilterBySourceSiteId() public async Task QueryAsync_FilterBySourceSiteId()
{ {
@@ -137,7 +240,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2))); await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync( var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count); Assert.Equal(2, rows.Count);
@@ -160,7 +263,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var rows = await repo.QueryAsync( var rows = await repo.QueryAsync(
new AuditLogQueryFilter( new AuditLogQueryFilter(
SourceSiteId: siteId, SourceSiteIds: new[] { siteId },
FromUtc: t0.AddMinutes(10), FromUtc: t0.AddMinutes(10),
ToUtc: t0.AddHours(1)), ToUtc: t0.AddHours(1)),
new AuditLogPaging(PageSize: 10)); new AuditLogPaging(PageSize: 10));
@@ -187,7 +290,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
} }
var page1 = await repo.QueryAsync( var page1 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 2)); new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count); Assert.Equal(2, page1.Count);
@@ -196,7 +299,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor = page1[^1]; var cursor = page1[^1];
var page2 = await repo.QueryAsync( var page2 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging( new AuditLogPaging(
PageSize: 2, PageSize: 2,
AfterOccurredAtUtc: cursor.OccurredAtUtc, AfterOccurredAtUtc: cursor.OccurredAtUtc,
@@ -208,7 +311,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor2 = page2[^1]; var cursor2 = page2[^1];
var page3 = await repo.QueryAsync( var page3 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId), new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging( new AuditLogPaging(
PageSize: 2, PageSize: 2,
AfterOccurredAtUtc: cursor2.OccurredAtUtc, AfterOccurredAtUtc: cursor2.OccurredAtUtc,
@@ -281,7 +384,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(e); await repo.InsertIfNotExistsAsync(e);
} }
var filter = new AuditLogQueryFilter(SourceSiteId: siteId); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId });
var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2)); var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count); Assert.Equal(2, page1.Count);