using Akka.Actor; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Update; using Shouldly; using Xunit; using ZB.MOM.WW.Audit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// R03:2.6 — one constraint hit used to drop the whole flush. The writer now detaches the rejected /// row(s) and re-saves, so a duplicate EventId costs exactly that row. /// public sealed class AuditWriterActorRejectedRowTests : ControlPlaneActorTestBase { [Fact] public void A_rejected_row_is_dropped_and_the_rest_of_the_batch_is_persisted() { var dbName = Guid.NewGuid().ToString("N"); var root = new InMemoryDatabaseRoot(); var duplicate = Guid.NewGuid(); var rejecting = new RejectingDbFactory(dbName, root, new RejectOnce(duplicate)); var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting)); var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList(); ids.Insert(7, duplicate); foreach (var id in ids) actor.Tell(NewEvent(id)); Watch(actor); actor.Tell(PoisonPill.Instance); // PostStop flushes ExpectTerminated(actor); using var db = rejecting.CreateDbContext(); var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList(); persisted.Count.ShouldBe(20, "every row but the rejected one lands"); persisted.ShouldNotContain(duplicate); // Positive control: the rows on either side of the rejected one are present. persisted.ShouldContain(ids[6]); persisted.ShouldContain(ids[8]); } /// A failure that names no entry is a whole-batch failure, exactly as before. [Fact] public void A_connection_level_failure_still_drops_the_batch_without_looping() { var dbName = Guid.NewGuid().ToString("N"); var root = new InMemoryDatabaseRoot(); var rejecting = new RejectingDbFactory(dbName, root, new AlwaysFailWithoutEntries()); var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting)); for (var i = 0; i < 5; i++) actor.Tell(NewEvent(Guid.NewGuid())); Watch(actor); actor.Tell(PoisonPill.Instance); ExpectTerminated(actor); // terminates ⇒ the retry loop did not spin using var db = rejecting.CreateDbContext(); db.ConfigAuditLogs.Count().ShouldBe(0); } private static AuditEvent NewEvent(Guid eventId) => new() { EventId = eventId, Category = "Config", Action = "Edit", Actor = "joe", OccurredAtUtc = DateTimeOffset.UtcNow, Outcome = AuditOutcome.Success, DetailsJson = "{}", SourceNode = "node-a", CorrelationId = Guid.NewGuid(), }; /// Throws a naming the entry for one EventId, the first time it is saved. private sealed class RejectOnce(Guid eventId) : ISaveChangesInterceptor { private int _fired; public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { if (_fired == 1 || eventData.Context is null) return result; var entry = eventData.Context.ChangeTracker.Entries() .FirstOrDefault(e => e.Entity.EventId == eventId); if (entry is null) return result; _fired = 1; // GetInfrastructure() hands back the pubternal InternalEntityEntry, which is the IUpdateEntry the // real provider puts on the exception; the test only needs that one cast. #pragma warning disable EF1001 var updateEntry = (IUpdateEntry)entry.GetInfrastructure(); #pragma warning restore EF1001 throw new DbUpdateException("Cannot insert duplicate key row (simulated 2601)", new[] { updateEntry }); } } private sealed class AlwaysFailWithoutEntries : ISaveChangesInterceptor { public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) => throw new DbUpdateException("connection lost (simulated)"); } private sealed class RejectingDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor interceptor) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new( new DbContextOptionsBuilder() .UseInMemoryDatabase(dbName, root) .AddInterceptors(interceptor) .Options); } }