feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 12:37:50 -04:00
parent 5aaf9e2923
commit db707bb0de
127 changed files with 2240 additions and 3886 deletions
@@ -1,14 +1,14 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;
/// <summary>
/// Schema-level tests for <see cref="AuditLogEntityTypeConfiguration"/> (#23 M1 Bundle B).
/// Verifies that <see cref="AuditEvent"/> maps to the AuditLog table with the
/// Verifies that <see cref="AuditLogRow"/> maps to the AuditLog table with the
/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4.
/// Inspects EF model metadata via the existing in-memory SQLite test context — no
/// database round-trips required.
@@ -34,7 +34,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
// AuditLog table — the clustered key must include the partition column
// (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C).
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
Assert.Equal("AuditLog", entity!.GetTableName());
@@ -43,7 +43,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.NotNull(pk);
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames);
Assert.Equal(new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, pkPropertyNames);
}
[Fact]
@@ -52,7 +52,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// EventId remains globally unique (the idempotency key for
// InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that
// is independent of the composite PK.
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var eventIdIndex = entity!.GetIndexes()
@@ -62,20 +62,20 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.True(eventIdIndex!.IsUnique);
var indexedProperty = Assert.Single(eventIdIndex.Properties);
Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name);
Assert.Equal(nameof(AuditLogRow.EventId), indexedProperty.Name);
}
[Fact]
public void Configure_HasExpectedPropertyCount()
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var properties = entity!.GetProperties()
.Where(p => !p.IsShadowProperty())
.ToList();
// AuditEvent record exposes 24 init-only properties (alog.md §4 plus the
// AuditLogRow record exposes 24 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column, its ParentExecutionId
// sibling, and the SourceNode-stamping column).
Assert.Equal(24, properties.Count);
@@ -84,7 +84,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
[Fact]
public void Configure_ExpectedIndexes_WithCorrectNames()
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var indexNames = entity!.GetIndexes()
@@ -115,13 +115,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
}
[Theory]
[InlineData(nameof(AuditEvent.Channel))]
[InlineData(nameof(AuditEvent.Kind))]
[InlineData(nameof(AuditEvent.Status))]
[InlineData(nameof(AuditEvent.ForwardState))]
[InlineData(nameof(AuditLogRow.Channel))]
[InlineData(nameof(AuditLogRow.Kind))]
[InlineData(nameof(AuditLogRow.Status))]
[InlineData(nameof(AuditLogRow.ForwardState))]
public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var property = entity!.FindProperty(propertyName);
@@ -136,7 +136,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
[Fact]
public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
{
// Insert an AuditEvent with an Unspecified-Kind DateTime, then re-read
// Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read
// it in a fresh context. The UtcConverter on the OccurredAtUtc /
// IngestedAtUtc columns must re-tag the round-tripped value as
// DateTimeKind.Utc. Without the converter the SQLite (and on production
@@ -147,10 +147,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var eventId = Guid.NewGuid();
var siteId = "test-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var evt = new AuditEvent
var evt = new AuditLogRow
{
EventId = eventId,
// The AuditEvent record's init-setter (Commons-019 resolution)
// The AuditLogRow record's init-setter (Commons-019 resolution)
// re-tags Unspecified values as Utc on assignment, so the value EF
// ultimately writes already has Kind=Utc. The converter's job is
// to keep the Kind tag on the READ path, which the assertions
@@ -163,14 +163,14 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
SourceSiteId = siteId,
};
_context.Set<AuditEvent>().Add(evt);
_context.Set<AuditLogRow>().Add(evt);
await _context.SaveChangesAsync();
// Detach the tracked entity and re-read in a fresh query so we exercise
// the actual hydrate path, not the change-tracker cache.
_context.ChangeTracker.Clear();
var loaded = await _context.Set<AuditEvent>()
var loaded = await _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(e => e.SourceSiteId == siteId)
.SingleAsync();
@@ -192,14 +192,14 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// future config refactor accidentally removing the HasConversion calls.
// The converter type itself is internal to the configuration, so we
// just assert SOME converter is present on each *Utc DateTime column.
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var occurredAt = entity!.FindProperty(nameof(AuditEvent.OccurredAtUtc));
var occurredAt = entity!.FindProperty(nameof(AuditLogRow.OccurredAtUtc));
Assert.NotNull(occurredAt);
Assert.NotNull(occurredAt!.GetValueConverter());
var ingestedAt = entity.FindProperty(nameof(AuditEvent.IngestedAtUtc));
var ingestedAt = entity.FindProperty(nameof(AuditLogRow.IngestedAtUtc));
Assert.NotNull(ingestedAt);
Assert.NotNull(ingestedAt!.GetValueConverter());
}
@@ -207,7 +207,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
[Fact]
public void Configure_FilteredIndexes_HaveExpectedFilters()
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var correlationIdx = entity!.GetIndexes()