diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index ca914d3..9ad90e0 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -15,7 +15,15 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.EventId); + // Composite PK includes OccurredAtUtc — required by the monthly partition scheme + // (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still + // needs to be globally unique for InsertIfNotExistsAsync idempotency, so a + // separate unique index is declared on EventId alone. + builder.HasKey(e => new { e.EventId, e.OccurredAtUtc }); + + builder.HasIndex(e => e.EventId) + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); // Enum-as-string columns: bounded varchar(32) ASCII. builder.Property(e => e.Channel) diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index b70ee0f..9427442 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -28,8 +28,11 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable } [Fact] - public void Configure_MapsToAuditLogTable_WithEventIdAsPrimaryKey() + public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey() { + // 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)); Assert.NotNull(entity); @@ -37,8 +40,28 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable var pk = entity.FindPrimaryKey(); Assert.NotNull(pk); - var pkProperty = Assert.Single(pk!.Properties); - Assert.Equal(nameof(AuditEvent.EventId), pkProperty.Name); + + var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray(); + Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames); + } + + [Fact] + public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups() + { + // 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)); + Assert.NotNull(entity); + + var eventIdIndex = entity!.GetIndexes() + .SingleOrDefault(i => i.GetDatabaseName() == "UX_AuditLog_EventId"); + + Assert.NotNull(eventIdIndex); + Assert.True(eventIdIndex!.IsUnique); + + var indexedProperty = Assert.Single(eventIdIndex.Properties); + Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name); } [Fact] @@ -56,7 +79,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable } [Fact] - public void Configure_FiveExpectedIndexes_WithCorrectNames() + public void Configure_ExpectedIndexes_WithCorrectNames() { var entity = _context.Model.FindEntityType(typeof(AuditEvent)); Assert.NotNull(entity); @@ -66,6 +89,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .OrderBy(n => n, StringComparer.Ordinal) .ToList(); + // Five reconciliation/query indexes from alog.md §4, plus the EventId unique + // index introduced alongside the composite PK (Bundle C). var expected = new[] { "IX_AuditLog_Channel_Status_Occurred", @@ -73,6 +98,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable "IX_AuditLog_OccurredAtUtc", "IX_AuditLog_Site_Occurred", "IX_AuditLog_Target_Occurred", + "UX_AuditLog_EventId", }; Assert.Equal(expected, indexNames);