feat(auditlog): multi-value AuditLogQueryFilter dimensions
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>>=</c> / <c><=</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>>=</c> / <c><=</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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user