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 AlwaysRejectBatchContaining(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]); } /// /// Fix round 1 / I4 — a FAITHFUL SQL Server shape: the provider sends the batch as one statement batch, /// so the for one duplicate names EVERY entry in it. Detaching /// ex.Entries therefore dropped the whole flush. The writer must drop only the rows whose /// EventId is already persisted and keep the rest. /// [Fact] public void A_whole_batch_rejection_drops_only_the_rows_already_persisted() { var dbName = Guid.NewGuid().ToString("N"); var root = new InMemoryDatabaseRoot(); var factory = new RejectingDbFactory(dbName, root, new RejectWholeBatchOnDuplicate()); var duplicate = Guid.NewGuid(); using (var seed = factory.CreateDbContext()) { seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate }); seed.SaveChanges(); } var actor = Sys.ActorOf(AuditWriterActor.Props(factory)); 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); ExpectTerminated(actor); using var db = factory.CreateDbContext(); var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList(); persisted.Count.ShouldBe(21, "the 20 new rows land beside the one that was already there"); persisted.Count(id => id == duplicate).ShouldBe(1); db.ConfigAuditLogs.Single(r => r.EventId == duplicate).Principal.ShouldBe("earlier"); } /// The same contract against the REAL provider and the real filtered unique index. [Fact] [Trait("Category", "SchemaCompliance")] public void On_sql_server_a_duplicate_event_id_costs_only_that_row() { using var sql = new SqlServerTestDatabase(); var duplicate = Guid.NewGuid(); using (var seed = sql.NewContext()) { seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate }); seed.SaveChanges(); } var actor = Sys.ActorOf(AuditWriterActor.Props(sql.Factory())); 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); ExpectTerminated(actor, PresenceBudget); using var db = sql.NewContext(); var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList(); persisted.Count.ShouldBe(21); persisted.Count(id => id == duplicate).ShouldBe(1); foreach (var id in ids) persisted.ShouldContain(id); } /// 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(), }; /// /// A row the database refuses on its own terms (not a duplicate — e.g. a per-row constraint), every /// time it is in the save. Faithful to SQL Server's batch shape: the exception names EVERY added /// entry, so only a row-by-row retry can isolate the offender. /// private sealed class AlwaysRejectBatchContaining(Guid eventId) : ISaveChangesInterceptor { public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { if (eventData.Context is null) return result; var added = eventData.Context.ChangeTracker.Entries() .Where(e => e.State == EntityState.Added).ToList(); if (!added.Any(e => e.Entity.EventId == eventId)) return result; #pragma warning disable EF1001 var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList(); #pragma warning restore EF1001 throw new DbUpdateException("The statement conflicted with a CHECK constraint (simulated, whole batch)", entries); } } /// /// Models SQL Server's batched insert: when ANY added row's EventId is already stored, the whole save /// fails with an exception naming EVERY added entry (the InMemory provider enforces no unique index, /// so this interceptor is the constraint). /// private sealed class RejectWholeBatchOnDuplicate : ISaveChangesInterceptor { public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { if (eventData.Context is not OtOpcUaConfigDbContext ctx) return result; var added = ctx.ChangeTracker.Entries().Where(e => e.State == EntityState.Added).ToList(); var ids = added.Select(e => e.Entity.EventId).Where(id => id != null).ToList(); if (ids.Count == 0 || !ctx.ConfigAuditLogs.AsNoTracking().Any(r => ids.Contains(r.EventId))) return result; #pragma warning disable EF1001 var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList(); #pragma warning restore EF1001 throw new DbUpdateException("Cannot insert duplicate key row (simulated 2601, whole batch)", entries); } } 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); } }