feat(configdb): map SiteCall to SiteCalls table (#22, #23 M3)

Bundle B1 of Audit Log #23 M3: introduces the SiteCall entity + EF mapping
for the central SiteCalls operational-state table. One row per
TrackedOperationId, mirrored from sites via best-effort telemetry then
periodic reconciliation; eventually-consistent mirror, not a dispatcher.

- src/ScadaLink.Commons/Entities/Audit/SiteCall.cs: append-once record
  with required TrackedOperationId/Channel/Target/SourceSite/Status,
  monotonic status update at the repo layer.
- src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs:
  table SiteCalls, PK on TrackedOperationId (stored as varchar(36) via
  value conversion through the canonical 'D'-format GUID string —
  matches the wire shape used by gRPC + SQLite columns), two named
  indexes (IX_SiteCalls_Source_Created, IX_SiteCalls_Status_Updated).
- ScadaLinkDbContext: DbSet<SiteCall> SiteCalls in the existing Audit
  section, after AuditLogs.
- Tests in tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/:
  table name, PK, value-conversion shape, index presence + ordering.
This commit is contained in:
Joseph Doherty
2026-05-20 14:04:17 -04:00
parent e416b21dad
commit 3162286ade
4 changed files with 245 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Configurations;
namespace ScadaLink.ConfigurationDatabase.Tests.Configurations;
/// <summary>
/// Schema-level tests for <see cref="SiteCallEntityTypeConfiguration"/> (#22 / #23 M3 Bundle B).
/// Verifies the <see cref="SiteCall"/> record maps to the <c>SiteCalls</c> table with the
/// expected primary key, value conversion on <c>TrackedOperationId</c>, and the two named
/// indexes that back the "calls from this site" and "calls in this status" Central UI queries.
/// Mirrors the AuditLog Bundle B test pattern — inspects EF model metadata via the existing
/// in-memory SQLite test context, no database round-trips required.
/// </summary>
public class SiteCallEntityTypeConfigurationTests : IDisposable
{
private readonly ScadaLinkDbContext _context;
public SiteCallEntityTypeConfigurationTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
[Fact]
public void Configure_MapsToSiteCallsTable()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
Assert.Equal("SiteCalls", entity!.GetTableName());
}
[Fact]
public void Configure_PrimaryKey_TrackedOperationId()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var pk = entity!.FindPrimaryKey();
Assert.NotNull(pk);
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames);
}
[Fact]
public void Configure_HasIndexes_NamedAndOrdered()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var indexes = entity!.GetIndexes().ToList();
// IX_SiteCalls_Source_Created: (SourceSite ASC, CreatedAtUtc DESC).
var sourceCreated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Source_Created");
Assert.NotNull(sourceCreated);
var sourceCreatedProps = sourceCreated!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.CreatedAtUtc) }, sourceCreatedProps);
// IX_SiteCalls_Status_Updated: (Status ASC, UpdatedAtUtc DESC).
var statusUpdated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Status_Updated");
Assert.NotNull(statusUpdated);
var statusUpdatedProps = statusUpdated!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps);
}
[Fact]
public void Configure_TrackedOperationId_ConvertedToString_Length36()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var property = entity!.FindProperty(nameof(SiteCall.TrackedOperationId));
Assert.NotNull(property);
// Stored as varchar(36) (TrackedOperationId.ToString("D") is always 36 chars).
// The value-conversion target type is exposed via GetProviderClrType when set, or
// discovered indirectly through the configured converter; either way the on-wire
// CLR type is string.
var providerClrType = property!.GetProviderClrType() ?? property.GetValueConverter()?.ProviderClrType;
Assert.Equal(typeof(string), providerClrType);
Assert.Equal(36, property.GetMaxLength());
Assert.False(property.IsUnicode() ?? true);
}
[Theory]
[InlineData(nameof(SiteCall.Channel), 32)]
[InlineData(nameof(SiteCall.SourceSite), 64)]
[InlineData(nameof(SiteCall.Status), 32)]
[InlineData(nameof(SiteCall.Target), 256)]
public void Configure_AsciiBoundedColumns(string propertyName, int expectedMaxLength)
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var property = entity!.FindProperty(propertyName);
Assert.NotNull(property);
Assert.Equal(expectedMaxLength, property!.GetMaxLength());
Assert.False(property.IsUnicode() ?? true);
}
}