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);
}
}