refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
using System.Text;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The
|
||||
/// service streams the filtered Audit Log query to a destination stream as
|
||||
/// RFC 4180 CSV. These tests pin:
|
||||
/// <list type="bullet">
|
||||
/// <item>Header + body row count for a simple page.</item>
|
||||
/// <item>RFC 4180 quoting for fields containing commas / quotes / CR-LF.</item>
|
||||
/// <item>Null fields render as empty (no literal "null").</item>
|
||||
/// <item>Row cap honoured + cap footer appended.</item>
|
||||
/// <item>Cancellation tokens propagate mid-stream.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AuditLogExportServiceTests
|
||||
{
|
||||
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
|
||||
=> new()
|
||||
{
|
||||
EventId = Guid.Parse(id),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
CorrelationId = null,
|
||||
SourceSiteId = "plant-a",
|
||||
SourceInstanceId = null,
|
||||
SourceScript = null,
|
||||
Actor = null,
|
||||
Target = target,
|
||||
Status = AuditStatus.Delivered,
|
||||
HttpStatus = 200,
|
||||
DurationMs = 42,
|
||||
ErrorMessage = error,
|
||||
ErrorDetail = null,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = null,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
|
||||
{
|
||||
var rows = new List<AuditEvent>
|
||||
{
|
||||
SimpleEvent("11111111-1111-1111-1111-111111111111"),
|
||||
SimpleEvent("22222222-2222-2222-2222-222222222222"),
|
||||
SimpleEvent("33333333-3333-3333-3333-333333333333"),
|
||||
SimpleEvent("44444444-4444-4444-4444-444444444444"),
|
||||
SimpleEvent("55555555-5555-5555-5555-555555555555"),
|
||||
};
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
// First call returns the 5 rows; subsequent calls return empty so the
|
||||
// service terminates the keyset loop.
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 100, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
var lines = csv.Split("\r\n", StringSplitOptions.None);
|
||||
|
||||
// 1 header + 5 rows + trailing empty token from final \r\n = 7 entries.
|
||||
Assert.Equal(7, lines.Length);
|
||||
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,SourceSiteId,", lines[0]);
|
||||
Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]);
|
||||
Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]);
|
||||
Assert.Equal(string.Empty, lines[6]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_HeaderHasAll21Columns_InSpecOrder()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray()).TrimEnd('\r', '\n');
|
||||
var header = csv.Split("\r\n")[0];
|
||||
var columns = header.Split(',');
|
||||
|
||||
Assert.Equal(21, columns.Length);
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind",
|
||||
"CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript",
|
||||
"Actor", "Target", "Status", "HttpStatus", "DurationMs",
|
||||
"ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary",
|
||||
"PayloadTruncated", "Extra", "ForwardState",
|
||||
}, columns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_FieldWithComma_QuotedAndEscaped()
|
||||
{
|
||||
// Target contains a comma → field must be wrapped in double quotes.
|
||||
// Target with embedded quote → quote must be doubled ("") and field quoted.
|
||||
// ResponseSummary contains CR-LF → field must be quoted.
|
||||
var row = new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
IngestedAtUtc = null,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
CorrelationId = null,
|
||||
SourceSiteId = "plant-a, secondary", // comma
|
||||
SourceInstanceId = null,
|
||||
SourceScript = "say \"hi\"", // embedded quote
|
||||
Actor = null,
|
||||
Target = "x",
|
||||
Status = AuditStatus.Delivered,
|
||||
HttpStatus = null,
|
||||
DurationMs = null,
|
||||
ErrorMessage = "boom\r\nthen again", // CR-LF
|
||||
ErrorDetail = null,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = null,
|
||||
};
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Comma-bearing field is quoted.
|
||||
Assert.Contains("\"plant-a, secondary\"", csv);
|
||||
// Embedded quote is doubled inside a quoted field.
|
||||
Assert.Contains("\"say \"\"hi\"\"\"", csv);
|
||||
// Newline-bearing field is quoted; the inner \r\n stays as-is.
|
||||
Assert.Contains("\"boom\r\nthen again\"", csv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_NullField_WrittenAsEmpty()
|
||||
{
|
||||
// Build a row with deliberate nulls for every nullable column.
|
||||
var row = new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
IngestedAtUtc = null,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
CorrelationId = null,
|
||||
SourceSiteId = null,
|
||||
SourceInstanceId = null,
|
||||
SourceScript = null,
|
||||
Actor = null,
|
||||
Target = null,
|
||||
Status = AuditStatus.Submitted,
|
||||
HttpStatus = null,
|
||||
DurationMs = null,
|
||||
ErrorMessage = null,
|
||||
ErrorDetail = null,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = null,
|
||||
};
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
var dataLine = csv.Split("\r\n")[1];
|
||||
var fields = dataLine.Split(',');
|
||||
|
||||
// EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4),
|
||||
// CorrelationId(5), SourceSiteId(6), SourceInstanceId(7), SourceScript(8),
|
||||
// Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13),
|
||||
// ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17),
|
||||
// PayloadTruncated(18), Extra(19), ForwardState(20)
|
||||
Assert.Equal(21, fields.Length);
|
||||
Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]);
|
||||
Assert.Equal(string.Empty, fields[2]); // IngestedAtUtc null
|
||||
Assert.Equal(string.Empty, fields[5]); // CorrelationId null
|
||||
Assert.Equal(string.Empty, fields[6]); // SourceSiteId null
|
||||
Assert.Equal(string.Empty, fields[12]); // HttpStatus null
|
||||
Assert.Equal(string.Empty, fields[14]); // ErrorMessage null
|
||||
Assert.Equal("False", fields[18]); // PayloadTruncated
|
||||
Assert.Equal(string.Empty, fields[20]); // ForwardState null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter()
|
||||
{
|
||||
// The service is asked for 3 rows but the repo would happily yield 5.
|
||||
// Output must contain exactly 3 data rows + a footer "# Capped at 3 rows..."
|
||||
var rows = Enumerable.Range(0, 5)
|
||||
.Select(i => SimpleEvent(Guid.NewGuid().ToString()))
|
||||
.ToList();
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
// Repo returns the 5 rows in a single page; the service must stop after 3.
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(rows));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 3, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
var lines = csv.Split("\r\n", StringSplitOptions.None);
|
||||
|
||||
// 1 header + 3 rows + 1 footer + trailing empty = 6 entries.
|
||||
Assert.Equal(6, lines.Length);
|
||||
Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_CancellationToken_StopsMidStream()
|
||||
{
|
||||
// Repo yields a single page, then on the next page call we observe the
|
||||
// canceled token and throw — service should propagate OperationCanceled.
|
||||
var cts = new CancellationTokenSource();
|
||||
var firstPage = new List<AuditEvent>
|
||||
{
|
||||
SimpleEvent("11111111-1111-1111-1111-111111111111"),
|
||||
SimpleEvent("22222222-2222-2222-2222-222222222222"),
|
||||
};
|
||||
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
// Cancel after delivering the first page so the next loop iteration
|
||||
// sees a canceled token.
|
||||
cts.Cancel();
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(firstPage);
|
||||
});
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
// The service writes the first page then checks the token before pulling
|
||||
// the next — we expect OperationCanceledException to surface.
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_PaginatesUsingLastRowAsCursor()
|
||||
{
|
||||
// Two pages of 2 rows each, then empty. The service must pass the last
|
||||
// row of page 1 as the cursor on the page-2 call.
|
||||
var p1 = new List<AuditEvent>
|
||||
{
|
||||
new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
||||
new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
||||
};
|
||||
var p2 = new List<AuditEvent>
|
||||
{
|
||||
new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
||||
};
|
||||
|
||||
var pagings = new List<AuditLogPaging>();
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => pagings.Add(p)), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(p1),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(p2),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
// PageSize is 2 so the first page returns full and the loop continues.
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None, pageSize: 2);
|
||||
|
||||
Assert.True(pagings.Count >= 2, $"Expected at least 2 paged calls, got {pagings.Count}");
|
||||
Assert.Null(pagings[0].AfterEventId);
|
||||
Assert.Null(pagings[0].AfterOccurredAtUtc);
|
||||
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
|
||||
Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
|
||||
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
|
||||
/// these tests pin the filter forwarding contract and the 100-row default-page-size
|
||||
/// rule the grid relies on.
|
||||
/// </summary>
|
||||
public class AuditLogQueryServiceTests
|
||||
{
|
||||
private static ICentralHealthAggregator EmptyAggregator()
|
||||
{
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
|
||||
return agg;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
|
||||
var paging = new AuditLogPaging(PageSize: 25);
|
||||
var expected = new List<AuditEvent>
|
||||
{
|
||||
new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
|
||||
};
|
||||
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
var result = await sut.QueryAsync(filter, paging);
|
||||
|
||||
Assert.Same(expected, result);
|
||||
await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
AuditLogPaging? observed = null;
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
|
||||
|
||||
Assert.NotNull(observed);
|
||||
Assert.Equal(sut.DefaultPageSize, observed!.PageSize);
|
||||
Assert.Equal(100, sut.DefaultPageSize);
|
||||
Assert.Null(observed.AfterOccurredAtUtc);
|
||||
Assert.Null(observed.AfterEventId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var repoSnapshot = new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: 42,
|
||||
ErrorEventsLastHour: 7,
|
||||
BacklogTotal: 0, // repo leaves this at zero
|
||||
AsOfUtc: anchor);
|
||||
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(repoSnapshot));
|
||||
|
||||
// Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11.
|
||||
// Sum = 16 → backlog tile shows 16.
|
||||
var sites = new Dictionary<string, SiteHealthState>
|
||||
{
|
||||
["plant-a"] = StateWithBacklog("plant-a", pending: 5),
|
||||
["plant-b"] = StateWithBacklog("plant-b", pending: 11),
|
||||
};
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(sites);
|
||||
|
||||
var sut = new AuditLogQueryService(repo, agg);
|
||||
|
||||
var snapshot = await sut.GetKpiSnapshotAsync();
|
||||
|
||||
Assert.Equal(42, snapshot.TotalEventsLastHour);
|
||||
Assert.Equal(7, snapshot.ErrorEventsLastHour);
|
||||
Assert.Equal(16, snapshot.BacklogTotal);
|
||||
Assert.Equal(anchor, snapshot.AsOfUtc);
|
||||
|
||||
// The service requests a 1-hour trailing window and lets the repo
|
||||
// anchor nowUtc to its own clock — we leave the second parameter null.
|
||||
await repo.Received(1).GetKpiSnapshotAsync(
|
||||
TimeSpan.FromHours(1),
|
||||
Arg.Is<DateTime?>(v => v == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
|
||||
|
||||
// plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog.
|
||||
var sites = new Dictionary<string, SiteHealthState>
|
||||
{
|
||||
["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true },
|
||||
["plant-b"] = StateWithBacklog("plant-b", pending: null),
|
||||
["plant-c"] = StateWithBacklog("plant-c", pending: 4),
|
||||
};
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(sites);
|
||||
|
||||
var sut = new AuditLogQueryService(repo, agg);
|
||||
|
||||
var snapshot = await sut.GetKpiSnapshotAsync();
|
||||
|
||||
// Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero.
|
||||
Assert.Equal(4, snapshot.BacklogTotal);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// #23 M7 — DbContext concurrency race regression (Bundle H follow-up)
|
||||
//
|
||||
// The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized
|
||||
// auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME
|
||||
// scoped Blazor-circuit ScadaBridgeDbContext. EF Core then throws
|
||||
// "A second operation was started on this context instance before a previous
|
||||
// operation completed." AuditLogQueryService now opens its OWN DI scope per
|
||||
// QueryAsync call (scope-per-query) so it never shares the page's scoped
|
||||
// context — these tests pin that contract.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace()
|
||||
{
|
||||
// A real ScadaBridgeDbContext (SQLite in-memory) registered as SCOPED with
|
||||
// the real AuditLogRepository — exactly the shared-scoped-context shape
|
||||
// that produces the EF race when one context services two operations.
|
||||
using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<ScadaBridgeDbContext>(options =>
|
||||
options.UseSqlite(connection)
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));
|
||||
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
// Create the schema once on a throwaway scope.
|
||||
using (var seedScope = provider.CreateScope())
|
||||
{
|
||||
var ctx = seedScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
await ctx.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
||||
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
|
||||
|
||||
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
|
||||
|
||||
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
|
||||
// fresh DbContext, so this completes cleanly; with a shared scoped context
|
||||
// EF throws "A second operation was started on this context instance".
|
||||
var t1 = sut.QueryAsync(filter);
|
||||
var t2 = sut.QueryAsync(filter);
|
||||
|
||||
var results = await Task.WhenAll(t1, t2);
|
||||
|
||||
Assert.All(results, Assert.NotNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls()
|
||||
{
|
||||
// Two sequential calls must each resolve the repository from a distinct
|
||||
// scope — the service must never cache a single repository instance.
|
||||
var resolvedRepos = new List<IAuditLogRepository>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogRepository>(_ =>
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
resolvedRepos.Add(repo);
|
||||
return repo;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var sut = new AuditLogQueryService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
EmptyAggregator());
|
||||
|
||||
await sut.QueryAsync(new AuditLogQueryFilter());
|
||||
await sut.QueryAsync(new AuditLogQueryFilter());
|
||||
|
||||
// Each QueryAsync opened its own scope → two distinct repo instances.
|
||||
Assert.Equal(2, resolvedRepos.Count);
|
||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
|
||||
// a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
|
||||
// QueryAsync's scope-per-call contract on the production path.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
var expected = new List<ExecutionTreeNode>
|
||||
{
|
||||
new(executionId, null, 3,
|
||||
new[] { "ApiOutbound" }, new[] { "Delivered" },
|
||||
"plant-a", "boiler-3",
|
||||
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)),
|
||||
};
|
||||
repo.GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(expected));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
var result = await sut.GetExecutionTreeAsync(executionId);
|
||||
|
||||
Assert.Same(expected, result);
|
||||
await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
|
||||
{
|
||||
// The production ctor must resolve a fresh repository per call — same
|
||||
// scope-per-query contract QueryAsync upholds, so the page's auto-load
|
||||
// never shares the circuit-scoped DbContext.
|
||||
var resolvedRepos = new List<IAuditLogRepository>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogRepository>(_ =>
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
|
||||
resolvedRepos.Add(repo);
|
||||
return repo;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var sut = new AuditLogQueryService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
EmptyAggregator());
|
||||
|
||||
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
||||
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Equal(2, resolvedRepos.Count);
|
||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Task 15: SourceNode filter forwarding + distinct-nodes service contract.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository()
|
||||
{
|
||||
// The Audit Log page's new Node multi-select pushes its chip set into
|
||||
// AuditLogQueryFilter.SourceNodes; the service must thread it through
|
||||
// unchanged so the repository's IN-list applies.
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var filter = new AuditLogQueryFilter(
|
||||
SourceNodes: new[] { "central-a", "site-plant-a-node-a" });
|
||||
repo.QueryAsync(filter, Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
await sut.QueryAsync(filter);
|
||||
|
||||
await repo.Received(1).QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f =>
|
||||
f.SourceNodes != null
|
||||
&& f.SourceNodes.Count == 2
|
||||
&& f.SourceNodes.Contains("central-a")
|
||||
&& f.SourceNodes.Contains("site-plant-a-node-a")),
|
||||
Arg.Any<AuditLogPaging>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" };
|
||||
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<string>>(expected));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
var result = await sut.GetDistinctSourceNodesAsync();
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls()
|
||||
{
|
||||
// Two back-to-back calls within the 60s TTL must hit the repository
|
||||
// exactly once — the filter bar should never produce N DB hits when
|
||||
// the operator opens it twice in quick succession.
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a" }));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
var first = await sut.GetDistinctSourceNodesAsync();
|
||||
var second = await sut.GetDistinctSourceNodesAsync();
|
||||
|
||||
Assert.Equal(first, second);
|
||||
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
|
||||
{
|
||||
SiteAuditBacklogSnapshot? backlog = pending.HasValue
|
||||
? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0)
|
||||
: null;
|
||||
var report = new SiteHealthReport(
|
||||
SiteId: siteId,
|
||||
SequenceNumber: 1,
|
||||
ReportTimestamp: DateTimeOffset.UtcNow,
|
||||
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
||||
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
||||
ScriptErrorCount: 0,
|
||||
AlarmEvaluationErrorCount: 0,
|
||||
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
||||
DeadLetterCount: 0,
|
||||
DeployedInstanceCount: 0,
|
||||
EnabledInstanceCount: 0,
|
||||
DisabledInstanceCount: 0,
|
||||
SiteAuditBacklog: backlog);
|
||||
return new SiteHealthState
|
||||
{
|
||||
SiteId = siteId,
|
||||
LatestReport = report,
|
||||
LastReportReceivedAt = DateTimeOffset.UtcNow,
|
||||
LastHeartbeatAt = DateTimeOffset.UtcNow,
|
||||
LastSequenceNumber = 1,
|
||||
IsOnline = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user