Bundle B2 of Audit Log #23 M3: EF-generated migration that creates the SiteCalls operational-state table on [PRIMARY], with the simple clustered PK on TrackedOperationId and the two named indexes the entity config declares. No partition function / scheme / DB-role restriction — SiteCalls holds mutable operational state (insert-once + monotonic-status update at the repo layer), unlike the partitioned append-only AuditLog table from M1. - Migration: 20260520180431_AddSiteCallsTable.cs (auto-generated; EF emitted CREATE TABLE + 2 indexes without customisation needed). - Model snapshot updated alongside. - Integration test: tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/ AddSiteCallsTableMigrationTests.cs. Uses the existing MsSqlMigrationFixture with [SkippableFact] + Skip.IfNot(fixture.Available). Asserts table + twelve columns + PK on TrackedOperationId + both named indexes.
This commit is contained in:
1619
src/ScadaLink.ConfigurationDatabase/Migrations/20260520180431_AddSiteCallsTable.Designer.cs
generated
Normal file
1619
src/ScadaLink.ConfigurationDatabase/Migrations/20260520180431_AddSiteCallsTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSiteCallsTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SiteCalls",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TrackedOperationId = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: false),
|
||||||
|
Channel = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
|
||||||
|
Target = table.Column<string>(type: "varchar(256)", unicode: false, maxLength: 256, nullable: false),
|
||||||
|
SourceSite = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
|
||||||
|
RetryCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LastError = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||||
|
HttpStatus = table.Column<int>(type: "int", nullable: true),
|
||||||
|
CreatedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
TerminalAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
IngestedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SiteCalls", x => x.TrackedOperationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SiteCalls_Source_Created",
|
||||||
|
table: "SiteCalls",
|
||||||
|
columns: new[] { "SourceSite", "CreatedAtUtc" },
|
||||||
|
descending: new[] { false, true });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SiteCalls_Status_Updated",
|
||||||
|
table: "SiteCalls",
|
||||||
|
columns: new[] { "Status", "UpdatedAtUtc" },
|
||||||
|
descending: new[] { false, true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SiteCalls");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,6 +212,72 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
b.ToTable("AuditLogEntries");
|
b.ToTable("AuditLogEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("TrackedOperationId")
|
||||||
|
.HasMaxLength(36)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Channel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int?>("HttpStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("IngestedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<int>("RetryCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SourceSite")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Target")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("TerminalAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("TrackedOperationId");
|
||||||
|
|
||||||
|
b.HasIndex("SourceSite", "CreatedAtUtc")
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_SiteCalls_Source_Created");
|
||||||
|
|
||||||
|
b.HasIndex("Status", "UpdatedAtUtc")
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_SiteCalls_Status_Updated");
|
||||||
|
|
||||||
|
b.ToTable("SiteCalls", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
|
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle B2 (#22, #23 M3) integration tests for the <c>AddSiteCallsTable</c>
|
||||||
|
/// migration: applies the EF migrations to a freshly-created MSSQL test database
|
||||||
|
/// on the running infra/mssql container and asserts that the resulting
|
||||||
|
/// <c>SiteCalls</c> table carries the expected columns, primary key, and the
|
||||||
|
/// two named operational indexes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unlike <c>AddAuditLogTable</c>, the SiteCalls table is operational (mutable)
|
||||||
|
/// state — no partition function, no partition scheme, no DB-role restriction.
|
||||||
|
/// Standard <c>[PRIMARY]</c> filegroup. Tests pair <see cref="SkippableFactAttribute"/>
|
||||||
|
/// with <c>Skip.IfNot(...)</c> so the runner reports them as Skipped (not Passed)
|
||||||
|
/// when MSSQL is unreachable. The fixture applies the migration once at
|
||||||
|
/// construction time.
|
||||||
|
/// </remarks>
|
||||||
|
public class AddSiteCallsTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public AddSiteCallsTableMigrationTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesSiteCallsTable_WithExpectedColumns()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var exists = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
|
||||||
|
"WHERE TABLE_NAME = 'SiteCalls' AND TABLE_SCHEMA = 'dbo';");
|
||||||
|
Assert.Equal(1, exists);
|
||||||
|
|
||||||
|
// Every required column from SiteCall + IngestedAtUtc. We don't pin types
|
||||||
|
// here because EF's CreateTable layer already encodes them; the
|
||||||
|
// entity-config tests cover length / unicode / nullability for the
|
||||||
|
// value-converted PK column. Just confirm the schema has all twelve.
|
||||||
|
var expectedColumns = new[]
|
||||||
|
{
|
||||||
|
"TrackedOperationId",
|
||||||
|
"Channel",
|
||||||
|
"Target",
|
||||||
|
"SourceSite",
|
||||||
|
"Status",
|
||||||
|
"RetryCount",
|
||||||
|
"LastError",
|
||||||
|
"HttpStatus",
|
||||||
|
"CreatedAtUtc",
|
||||||
|
"UpdatedAtUtc",
|
||||||
|
"TerminalAtUtc",
|
||||||
|
"IngestedAtUtc",
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var column in expectedColumns)
|
||||||
|
{
|
||||||
|
var present = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||||
|
$"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = '{column}';");
|
||||||
|
Assert.True(present == 1, $"Expected SiteCalls.{column} to exist; found {present}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesPK_OnTrackedOperationId()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
// Walk sys.indexes for the table's clustered PK index and confirm its
|
||||||
|
// single key column is TrackedOperationId. SiteCalls is non-partitioned
|
||||||
|
// so the PK is a simple single-column clustered index.
|
||||||
|
var pkColumn = await ScalarAsync<string?>(
|
||||||
|
"SELECT c.name FROM sys.indexes i " +
|
||||||
|
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||||
|
"INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id " +
|
||||||
|
"INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id " +
|
||||||
|
"WHERE o.name = 'SiteCalls' AND i.is_primary_key = 1;");
|
||||||
|
|
||||||
|
Assert.Equal("TrackedOperationId", pkColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesIndex_Source_Created()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var count = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||||
|
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||||
|
"WHERE o.name = 'SiteCalls' AND i.name = 'IX_SiteCalls_Source_Created';");
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesIndex_Status_Updated()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var count = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||||
|
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||||
|
"WHERE o.name = 'SiteCalls' AND i.name = 'IX_SiteCalls_Status_Updated';");
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task<T> ScalarAsync<T>(string sql)
|
||||||
|
{
|
||||||
|
await using var conn = _fixture.OpenConnection();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
var result = await cmd.ExecuteScalarAsync();
|
||||||
|
if (result is null || result is DBNull)
|
||||||
|
{
|
||||||
|
return default!;
|
||||||
|
}
|
||||||
|
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user