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; /// /// Bundle C1 (#22, #23 M3) tests for . Uses the /// same as the Bundle B3 repository tests /// so the actor exercises the real monotonic-upsert SQL end to end against the /// SiteCalls schema. Each test scopes its data by minting a fresh /// (and a per-test SourceSite suffix) /// so tests neither collide nor require teardown. /// public class SiteCallAuditActorTests : TestKit, IClassFixture { private readonly MsSqlMigrationFixture _fixture; public SiteCallAuditActorTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } private ScadaLinkDbContext CreateContext() { var options = new DbContextOptionsBuilder() .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.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(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() .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(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(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() .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(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(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() .Where(s => s.SourceSite == siteId) .ToListAsync(); Assert.Single(rows); Assert.Equal(healthyId, rows[0].TrackedOperationId); } /// /// Tiny test double that delegates to a real repository but throws on a /// specified . Used to verify the actor's /// fault-isolation behaviour: a transient repository failure must produce /// Accepted=false without crashing the singleton. /// 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 GetAsync(TrackedOperationId id, CancellationToken ct = default) => _inner.GetAsync(id, ct); public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => _inner.QueryAsync(filter, paging, ct); public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => _inner.PurgeTerminalAsync(olderThanUtc, ct); } }