Bundle C of Audit Log #23 M3. Adds the ScadaLink.SiteCallAudit project + matching tests project, mirroring the ScadaLink.AuditLog scaffolding pattern (net10.0, central package management, InternalsVisibleTo to the tests assembly). SiteCallAuditActor is the central singleton entry point for Site Call Audit (#22): it receives UpsertSiteCallCommand and persists the SiteCall via ISiteCallAuditRepository.UpsertAsync (monotonic, idempotent — out-of-order or duplicate updates are silent no-ops at the repo). Audit-write failures NEVER abort the user-facing action (CLAUDE.md): repository throws are caught + logged, the actor replies Accepted=false, and the singleton stays alive (Resume supervisor strategy as defence in depth). Two constructors mirror AuditLogIngestActor: - IServiceProvider production constructor resolves the scoped EF repository from a fresh DI scope per message. - ISiteCallAuditRepository test constructor injects a concrete repository so the TestKit tests exercise the real monotonic-upsert SQL end to end. UpsertSiteCallCommand + UpsertSiteCallReply live in ScadaLink.Commons (same home as IngestAuditEventsCommand) so Bundle D's gRPC server can construct them without taking a project reference on the actor's host project. AddSiteCallAudit() is a placeholder for symmetry with AddAuditLog / AddNotificationOutbox; Bundle F will populate it with the actor's Props factory + options bindings. Tests (Akka.TestKit.Xunit2 + MsSqlMigrationFixture via project ref to ScadaLink.ConfigurationDatabase.Tests, mirroring Bundle D2): - Receive_UpsertSiteCallCommand_Persists_Replies_Accepted - Receive_DuplicateUpsert_OlderStatus_NoOp_StillRepliesAccepted (idempotency) - Receive_RepoThrowsTransient_RepliesAccepted_False_ActorStaysAlive Reconciliation, KPIs, and the central->site Retry/Discard relay are deferred per CLAUDE.md scope discipline. ScadaLink.slnx updated to include both new projects. All 3 new tests pass against the running infra/mssql container; full suite (2683 tests across 27 projects) passes with no regressions.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
<Project Path="src/ScadaLink.ExternalSystemGateway/ScadaLink.ExternalSystemGateway.csproj" />
|
||||
<Project Path="src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
|
||||
<Project Path="src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" />
|
||||
<Project Path="src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj" />
|
||||
<Project Path="src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
|
||||
<Project Path="src/ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||
<Project Path="src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
||||
@@ -35,6 +36,7 @@
|
||||
<Project Path="tests/ScadaLink.ExternalSystemGateway.Tests/ScadaLink.ExternalSystemGateway.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.NotificationService.Tests/ScadaLink.NotificationService.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.Security.Tests/ScadaLink.Security.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.HealthMonitoring.Tests/ScadaLink.HealthMonitoring.Tests.csproj" />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
|
||||
namespace ScadaLink.Commons.Messages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Akka message sent to the central <c>SiteCallAuditActor</c> (Site Call Audit
|
||||
/// #22, Audit Log #23 M3 Bundle C) carrying one <see cref="SiteCall"/> row to
|
||||
/// be persisted via <c>ISiteCallAuditRepository.UpsertAsync</c>. The repository
|
||||
/// performs an insert-if-not-exists then monotonic update — duplicate gRPC
|
||||
/// packets and reconciliation pulls can both feed the actor without rolling
|
||||
/// state back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lives in <c>ScadaLink.Commons</c> rather than <c>ScadaLink.SiteCallAudit</c>
|
||||
/// so the gRPC server in <c>ScadaLink.Communication</c> can construct it
|
||||
/// without taking a project reference on the actor's host project (Bundle D
|
||||
/// adds the IngestCachedTelemetry RPC that will Tell this command).
|
||||
/// </remarks>
|
||||
public sealed record UpsertSiteCallCommand(SiteCall SiteCall);
|
||||
14
src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallReply.cs
Normal file
14
src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallReply.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Commons.Messages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Reply from the central <c>SiteCallAuditActor</c> for an
|
||||
/// <see cref="UpsertSiteCallCommand"/>. <see cref="Accepted"/> is <c>true</c>
|
||||
/// when the upsert reached the repository without throwing (including the
|
||||
/// monotonic-no-op case where the stored status' rank wins) and <c>false</c>
|
||||
/// when persistence raised an exception. The actor itself stays alive in
|
||||
/// either case — audit-write failures must NEVER abort the user-facing action
|
||||
/// (Audit Log #23 §13).
|
||||
/// </summary>
|
||||
public sealed record UpsertSiteCallReply(TrackedOperationId TrackedOperationId, bool Accepted);
|
||||
31
src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj
Normal file
31
src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj
Normal file
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- SiteCallAuditActor is an Akka actor (central singleton in Bundle F); Akka is an explicit dependency. -->
|
||||
<PackageReference Include="Akka" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<!-- Site Call Audit (#22) sits alongside Notification Outbox (#21) and Audit Log (#23).
|
||||
ISiteCallAuditRepository is registered by ScadaLink.ConfigurationDatabase; the
|
||||
project reference is documented here so the actor's scope-per-message
|
||||
GetRequiredService<ISiteCallAuditRepository>() compiles. -->
|
||||
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ScadaLink.SiteCallAudit.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
39
src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs
Normal file
39
src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ScadaLink.SiteCallAudit;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root for the Site Call Audit (#22) component.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M3 Bundle C ships the ingest-only minimum surface (the actor itself); the
|
||||
/// full DI surface — reconciliation puller, KPI projector, central→site
|
||||
/// Retry/Discard relay, options + validators — is deferred to a follow-up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The repository (<c>ISiteCallAuditRepository</c>) is registered by
|
||||
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
|
||||
/// so callers (the Host on the central node) must also call that. The actor's
|
||||
/// <c>Props</c> are wired up in Host registration (Bundle F); this extension
|
||||
/// is currently a no-op placeholder kept for symmetry with the AuditLog and
|
||||
/// NotificationOutbox composition roots — adding it now means consumers can
|
||||
/// reference the method without re-touching the Host project later.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers Site Call Audit (#22) services. Currently a no-op
|
||||
/// placeholder — Bundle F will populate this with the actor's Props
|
||||
/// factory + options bindings. The method is exposed now so the Host
|
||||
/// wiring call already exists at the API boundary.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSiteCallAudit(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
// Actor props are constructed in Host wiring (Bundle F). This
|
||||
// extension is a placeholder for future config + DI.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
140
src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs
Normal file
140
src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
|
||||
namespace ScadaLink.SiteCallAudit;
|
||||
|
||||
/// <summary>
|
||||
/// Central singleton for Site Call Audit (#22). Receives
|
||||
/// <see cref="UpsertSiteCallCommand"/> messages and persists each
|
||||
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> row via
|
||||
/// <see cref="ISiteCallAuditRepository.UpsertAsync"/> — idempotent monotonic
|
||||
/// upsert. Out-of-order or duplicate updates are silent no-ops at the
|
||||
/// repository layer; the actor always replies <see cref="UpsertSiteCallReply"/>
|
||||
/// with <c>Accepted=true</c> in that case because storage state is consistent
|
||||
/// and the site is free to consider its packet acked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M3 ships the minimum surface: ingest only. Reconciliation, KPIs, and
|
||||
/// central→site Retry/Discard relay are deferred (per CLAUDE.md scope
|
||||
/// discipline — Site Call Audit's KPIs and the Retry/Discard relay land in a
|
||||
/// follow-up).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
|
||||
/// the actor catches every exception from the repository call and replies
|
||||
/// <c>Accepted=false</c> without rethrowing, so the central singleton stays
|
||||
/// alive. The <see cref="SupervisorStrategy"/> uses <c>Resume</c> so an
|
||||
/// unexpected throw before the catch (defence in depth) does not restart the
|
||||
/// actor and reset in-flight state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two constructors exist for the same reason as
|
||||
/// <c>AuditLogIngestActor</c>: production wiring (Bundle F) resolves the
|
||||
/// scoped EF repository from a fresh DI scope per message because the actor
|
||||
/// is a long-lived cluster singleton, while tests inject a concrete
|
||||
/// <see cref="ISiteCallAuditRepository"/> against a per-test MSSQL fixture
|
||||
/// so the actor exercises the real monotonic upsert SQL end to end.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
private readonly IServiceProvider? _serviceProvider;
|
||||
private readonly ISiteCallAuditRepository? _injectedRepository;
|
||||
private readonly ILogger<SiteCallAuditActor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Test-mode constructor — injects a concrete repository instance whose
|
||||
/// lifetime exceeds the test, so the actor reuses the same instance
|
||||
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
|
||||
/// </summary>
|
||||
public SiteCallAuditActor(
|
||||
ISiteCallAuditRepository repository,
|
||||
ILogger<SiteCallAuditActor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_injectedRepository = repository;
|
||||
_logger = logger;
|
||||
|
||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — resolves <see cref="ISiteCallAuditRepository"/>
|
||||
/// from a fresh DI scope per message because the repository is a scoped EF
|
||||
/// Core service registered by <c>AddConfigurationDatabase</c>. The actor
|
||||
/// itself is a long-lived cluster singleton, so it cannot hold a scope
|
||||
/// across messages.
|
||||
/// </summary>
|
||||
public SiteCallAuditActor(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SiteCallAuditActor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
|
||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit-write failures are best-effort by design (CLAUDE.md §Audit): a
|
||||
/// thrown exception in the upsert pipeline must not crash the actor.
|
||||
/// Resume keeps the actor's state intact so the next packet is processed
|
||||
/// against the same repository instance.
|
||||
/// </summary>
|
||||
protected override SupervisorStrategy SupervisorStrategy()
|
||||
{
|
||||
return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
|
||||
Akka.Actor.SupervisorStrategy.DefaultDecider);
|
||||
}
|
||||
|
||||
private async Task OnUpsertAsync(UpsertSiteCallCommand cmd)
|
||||
{
|
||||
// Sender is captured before the first await — Akka resets Sender
|
||||
// between message dispatches, so a post-await Tell would go to
|
||||
// DeadLetters.
|
||||
var replyTo = Sender;
|
||||
var id = cmd.SiteCall.TrackedOperationId;
|
||||
|
||||
// Scope-per-message mirrors AuditLogIngestActor — production EF
|
||||
// repository is scoped; the injected-repository mode (tests) skips
|
||||
// the scope entirely.
|
||||
IServiceScope? scope = null;
|
||||
ISiteCallAuditRepository repository;
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
repository = _injectedRepository;
|
||||
}
|
||||
else
|
||||
{
|
||||
scope = _serviceProvider!.CreateScope();
|
||||
repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await repository.UpsertAsync(cmd.SiteCall).ConfigureAwait(false);
|
||||
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
|
||||
// action — log and reply Accepted=false; do NOT rethrow (the
|
||||
// central singleton MUST stay alive).
|
||||
_logger.LogError(ex, "SiteCallAudit upsert failed for {TrackedOperationId}", id);
|
||||
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: false));
|
||||
}
|
||||
finally
|
||||
{
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<!--
|
||||
MSSQL-backed SiteCallAuditActor tests use the MsSqlMigrationFixture
|
||||
(reused from ConfigurationDatabase.Tests). Pinning 6.1.1 here mirrors
|
||||
AuditLog.Tests: EF SqlServer 10.0.7 needs >= 6.1.1 but the central pin
|
||||
is 6.0.2 (production ExternalSystemGateway). Override is test-only.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<!--
|
||||
SkippableFact pattern (xunit 2.9.x has no native Assert.Skip) — used by
|
||||
the MSSQL-backed SiteCallAuditActor tests to report Skipped when the dev
|
||||
MSSQL container is not reachable.
|
||||
-->
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj" />
|
||||
<!--
|
||||
The actor tests use the real SiteCallAuditRepository against a per-test
|
||||
MSSQL database via MsSqlMigrationFixture. The fixture lives in
|
||||
ScadaLink.ConfigurationDatabase.Tests; we reference that test project so
|
||||
the fixture + EF migrations come along without duplicating them
|
||||
(same pattern as ScadaLink.AuditLog.Tests' Bundle D2).
|
||||
-->
|
||||
<ProjectReference Include="../ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
221
tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs
Normal file
221
tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
namespace ScadaLink.SiteCallAudit.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle C1 (#22, #23 M3) tests for <see cref="SiteCallAuditActor"/>. Uses the
|
||||
/// same <see cref="MsSqlMigrationFixture"/> as the Bundle B3 repository tests
|
||||
/// so the actor exercises the real monotonic-upsert SQL end to end against the
|
||||
/// <c>SiteCalls</c> schema. Each test scopes its data by minting a fresh
|
||||
/// <see cref="TrackedOperationId"/> (and a per-test <c>SourceSite</c> suffix)
|
||||
/// so tests neither collide nor require teardown.
|
||||
/// </summary>
|
||||
public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public SiteCallAuditActorTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private ScadaLinkDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.Options;
|
||||
return new ScadaLinkDbContext(options);
|
||||
}
|
||||
|
||||
private static string NewSiteId() =>
|
||||
"test-bundle-c1-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
private static SiteCall NewRow(
|
||||
TrackedOperationId id,
|
||||
string sourceSite,
|
||||
string status = "Submitted",
|
||||
int retryCount = 0,
|
||||
string? lastError = null,
|
||||
DateTime? createdAtUtc = null,
|
||||
DateTime? updatedAtUtc = null,
|
||||
bool terminal = false)
|
||||
{
|
||||
var created = createdAtUtc ?? DateTime.UtcNow;
|
||||
var updated = updatedAtUtc ?? created;
|
||||
return new SiteCall
|
||||
{
|
||||
TrackedOperationId = id,
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = sourceSite,
|
||||
Status = status,
|
||||
RetryCount = retryCount,
|
||||
LastError = lastError,
|
||||
HttpStatus = null,
|
||||
CreatedAtUtc = created,
|
||||
UpdatedAtUtc = updated,
|
||||
TerminalAtUtc = terminal ? updated : null,
|
||||
IngestedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(ISiteCallAuditRepository repository) =>
|
||||
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
||||
repository,
|
||||
NullLogger<SiteCallAuditActor>.Instance)));
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
var id = TrackedOperationId.New();
|
||||
var row = NewRow(id, siteId, status: "Submitted", retryCount: 0);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
actor.Tell(new UpsertSiteCallCommand(row), TestActor);
|
||||
|
||||
var reply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(reply.Accepted, "Actor should reply Accepted=true on a successful upsert.");
|
||||
Assert.Equal(id, reply.TrackedOperationId);
|
||||
|
||||
// Verify the row landed in MSSQL via a fresh context (separate from the
|
||||
// actor's repository context).
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.SourceSite == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("Submitted", rows[0].Status);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_DuplicateUpsert_OlderStatus_NoOp_StillRepliesAccepted()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Idempotency contract: a stale/duplicate packet (lower rank than the
|
||||
// stored status) is a silent no-op at the repository — the actor must
|
||||
// still reply Accepted=true so the site is free to consider its
|
||||
// packet acked. Storage state is consistent either way.
|
||||
var siteId = NewSiteId();
|
||||
var id = TrackedOperationId.New();
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
// Land Attempted (rank 2) first.
|
||||
actor.Tell(new UpsertSiteCallCommand(NewRow(id, siteId, status: "Attempted", retryCount: 1, lastError: "first")), TestActor);
|
||||
var firstReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(firstReply.Accepted);
|
||||
|
||||
// Late-arriving Submitted (rank 0) — must be no-op in storage and
|
||||
// still acked by the actor.
|
||||
actor.Tell(new UpsertSiteCallCommand(NewRow(id, siteId, status: "Submitted", retryCount: 0)), TestActor);
|
||||
var secondReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(secondReply.Accepted, "Stale upsert must still be acked (idempotent contract).");
|
||||
|
||||
// Storage must still show the rank-2 row, not rolled back.
|
||||
await using var readContext = CreateContext();
|
||||
var stored = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.TrackedOperationId == id)
|
||||
.ToListAsync();
|
||||
Assert.Single(stored);
|
||||
Assert.Equal("Attempted", stored[0].Status);
|
||||
Assert.Equal(1, stored[0].RetryCount);
|
||||
Assert.Equal("first", stored[0].LastError);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_RepoThrowsTransient_RepliesAccepted_False_ActorStaysAlive()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
|
||||
// action. The actor must catch the throw, reply Accepted=false, and
|
||||
// stay alive — a follow-up message on the same actor must still be
|
||||
// processed (the singleton cannot die on a transient repo error).
|
||||
var siteId = NewSiteId();
|
||||
var poisonId = TrackedOperationId.New();
|
||||
var healthyId = TrackedOperationId.New();
|
||||
|
||||
await using var context = CreateContext();
|
||||
var realRepo = new SiteCallAuditRepository(context);
|
||||
var wrappedRepo = new ThrowingRepository(realRepo, poisonId);
|
||||
var actor = CreateActor(wrappedRepo);
|
||||
|
||||
// Poison row — the wrapper throws when this id arrives.
|
||||
actor.Tell(new UpsertSiteCallCommand(NewRow(poisonId, siteId, status: "Submitted")), TestActor);
|
||||
var poisonReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(poisonReply.Accepted, "Actor should reply Accepted=false when the repo throws.");
|
||||
Assert.Equal(poisonId, poisonReply.TrackedOperationId);
|
||||
|
||||
// Healthy follow-up on the SAME actor — must still be processed
|
||||
// (singleton staying alive proves the actor did not crash).
|
||||
actor.Tell(new UpsertSiteCallCommand(NewRow(healthyId, siteId, status: "Submitted")), TestActor);
|
||||
var healthyReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(healthyReply.Accepted, "Actor must stay alive after a transient repo failure.");
|
||||
Assert.Equal(healthyId, healthyReply.TrackedOperationId);
|
||||
|
||||
// Verify storage: healthy row landed, poison row did not.
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.SourceSite == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Single(rows);
|
||||
Assert.Equal(healthyId, rows[0].TrackedOperationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny test double that delegates to a real repository but throws on a
|
||||
/// specified <see cref="TrackedOperationId"/>. Used to verify the actor's
|
||||
/// fault-isolation behaviour: a transient repository failure must produce
|
||||
/// <c>Accepted=false</c> without crashing the singleton.
|
||||
/// </summary>
|
||||
private sealed class ThrowingRepository : ISiteCallAuditRepository
|
||||
{
|
||||
private readonly ISiteCallAuditRepository _inner;
|
||||
private readonly TrackedOperationId _poisonId;
|
||||
|
||||
public ThrowingRepository(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
|
||||
{
|
||||
_inner = inner;
|
||||
_poisonId = poisonId;
|
||||
}
|
||||
|
||||
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
||||
{
|
||||
if (siteCall.TrackedOperationId == _poisonId)
|
||||
{
|
||||
throw new InvalidOperationException("simulated transient repo failure for poison row");
|
||||
}
|
||||
return _inner.UpsertAsync(siteCall, ct);
|
||||
}
|
||||
|
||||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||
_inner.GetAsync(id, ct);
|
||||
|
||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||
_inner.QueryAsync(filter, paging, ct);
|
||||
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user