shoc-backend/SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs

343 lines
15 KiB
C#
Raw Normal View History

using System.Reflection;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Migrations;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Xunit;
namespace SeaHavenIndustries.Tests;
// Shape evidence for the global Services registry migration (table,
// indexes, FK, idempotent additive seed) plus a SQL Server execution test that
// is silently skipped when no provider is reachable, mirroring existing
// provider-gated migration tests.
public class SH303ServicesRegistryMigrationTests
{
private const string LocalDbMaster =
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
[Fact]
public void Up_CreatesRegistryTablesUniqueIndexesFkAndIdempotentProblemSeed()
{
var operations = BuildOperations("Up");
var createTables = operations.OfType<CreateTableOperation>().ToList();
Assert.Equal(
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
createTables.Select(t => t.Name).ToHashSet());
var services = createTables.Single(t => t.Name == "Services");
Assert.Contains(services.Columns, c => c.Name == "NormalizedName" && (string)c.ColumnType! == "nvarchar(200)");
Assert.Contains(services.Columns, c => c.Name == "CompletionDocTemplateId" && c.IsNullable);
Assert.Contains(services.Columns, c => c.Name == "RequiresCompletionDocument" && !c.IsNullable);
Assert.Contains(services.Columns, c => c.Name == "IsActive" && !c.IsNullable);
var foreignKeys = services.ForeignKeys.Concat(
createTables.Single(t => t.Name == "ServiceWorkOrderTypes").ForeignKeys)
.ToList();
Assert.Contains(foreignKeys, fk =>
fk.Table == "Services"
&& fk.PrincipalTable == "CompletionDocTemplates"
&& fk.OnDelete == ReferentialAction.Restrict);
Assert.Contains(foreignKeys, fk =>
fk.Table == "ServiceWorkOrderTypes" && fk.PrincipalTable == "Services");
var indexes = operations.OfType<CreateIndexOperation>().ToList();
Assert.Contains(indexes, i =>
i.Table == "Services" && i.Columns.SequenceEqual(new[] { "NormalizedName" }) && i.IsUnique);
Assert.Contains(indexes, i =>
i.Table == "ServiceWorkOrderTypes"
&& i.Columns.SequenceEqual(new[] { "ServiceId", "WorkOrderType" })
&& i.IsUnique);
// The seed is raw additive SQL: read-only over DropdownOptions, idempotent
// via NormalizedName NOT EXISTS, and never mutates dropdown rows.
var seedSql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
Assert.Contains("[Category] = N'Problem'", seedSql);
Assert.Contains("[IsActive] = 1", seedSql);
Assert.Contains("NOT EXISTS", seedSql);
Assert.Contains("INSERT INTO [Services]", seedSql);
Assert.Contains("INSERT INTO [ServiceWorkOrderTypes]", seedSql);
Assert.DoesNotContain("UPDATE [DropdownOptions]", seedSql);
Assert.DoesNotContain("DELETE FROM [DropdownOptions]", seedSql);
Assert.DoesNotContain("DELETE [DropdownOptions]", seedSql);
}
[Fact]
public void Down_DropsRegistryTables()
{
var operations = BuildOperations("Down");
var drops = operations.OfType<DropTableOperation>().Select(d => d.Name).ToList();
Assert.Equal(
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
drops.ToHashSet());
Assert.Empty(operations.OfType<SqlOperation>());
}
[Fact]
public void SH187_Up_AddsNullableServiceLinkAndBackfillsLegacyLabels()
{
var operations = BuildOperations<SH187_WorkOrderServiceLink>("Up");
var columns = operations.OfType<AddColumnOperation>().ToList();
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceId" && c.IsNullable);
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceNameSnapshot"
&& c.IsNullable && c.MaxLength == 200);
Assert.Contains(operations.OfType<CreateIndexOperation>(), i =>
i.Table == "workOrders" && i.Columns.SequenceEqual(new[] { "ServiceId" }));
Assert.Contains(operations.OfType<AddForeignKeyOperation>(), fk =>
fk.Table == "workOrders"
&& fk.PrincipalTable == "Services"
&& fk.OnDelete == ReferentialAction.Restrict);
var sql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
Assert.Contains("UPDATE w", sql);
Assert.Contains("ServiceNameSnapshot", sql);
Assert.Contains("LEFT JOIN [Services]", sql);
}
[Fact]
public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable()
{
var masterConnectionString = await ResolveMasterConnectionStringAsync();
if (masterConnectionString == null)
return;
var dbName = $"SH303Registry_{Guid.NewGuid():N}";
var connectionString = WithDatabase(masterConnectionString, dbName);
try
{
await CreateDatabaseAsync(masterConnectionString, dbName);
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using (var seedSource = connection.CreateCommand())
{
seedSource.CommandText =
"""
CREATE TABLE DropdownOptions (
Id int NOT NULL IDENTITY PRIMARY KEY,
Category nvarchar(max) NOT NULL,
Value nvarchar(max) NOT NULL,
ParentValue nvarchar(max) NULL,
SortOrder int NOT NULL,
IsActive bit NOT NULL
);
CREATE TABLE CompletionDocTemplates (
Id int NOT NULL IDENTITY PRIMARY KEY,
Name nvarchar(200) NOT NULL,
ServiceKey nvarchar(max) NOT NULL,
TemplateUrl nvarchar(max) NOT NULL,
IsActive bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL
);
INSERT INTO DropdownOptions (Category, Value, SortOrder, IsActive) VALUES
(N'Problem', N'Leak', 0, 1),
(N'Problem', N'leak', 1, 1),
(N'Problem', N'No Power', 2, 1),
(N'Problem', N'Not Cooling', 3, 1),
(N'Problem', N'Not Heating', 4, 1),
(N'Problem', N'Alarm Fault', 5, 1),
(N'Problem', N'Broken/Damaged', 6, 1),
(N'Problem', N'Retired Ghost', 7, 0),
(N'Trade', N'Electrical', 0, 1);
""";
await seedSource.ExecuteNonQueryAsync();
}
await using var context = new ApplicationDbContext(
new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlServer(connection).Options);
await ApplyMigrationAsync(context, new SH303_ServicesRegistry());
await using (var servicesCmd = connection.CreateCommand())
{
servicesCmd.CommandText =
"""
SELECT [Name], [Trade], [IconKey]
FROM [Services]
ORDER BY [Name];
""";
await using var reader = await servicesCmd.ExecuteReaderAsync();
var rows = new List<string>();
while (await reader.ReadAsync())
rows.Add($"{reader.GetString(0)}|{reader.GetString(1)}|{reader.GetString(2)}");
// Repeated case variants collapse onto one deterministic row and the
// inactive value plus the non-Problem category never seed.
var expected = new[]
{
"Alarm Fault|Fire & Life Safety|fire-life-safety",
"Broken/Damaged|General Building & Handyman|general-building-handyman",
"Leak|Plumbing & Water Systems|plumbing-water-systems",
"No Power|Electrical|electrical",
"Not Cooling|HVAC|hvac",
"Not Heating|HVAC|hvac"
};
Assert.Equal(expected, rows.OrderBy(r => r).ToArray());
}
await using (var typesCmd = connection.CreateCommand())
{
typesCmd.CommandText =
"""
SELECT COUNT(*)
FROM [ServiceWorkOrderTypes] t
JOIN [Services] s ON s.[Id] = t.[ServiceId]
WHERE t.[WorkOrderType] IN (2, 3, 6);
""";
Assert.Equal(18, Convert.ToInt32(await typesCmd.ExecuteScalarAsync())); // 6 services x 3 supported types
}
await using (var untouchedCmd = connection.CreateCommand())
{
untouchedCmd.CommandText = "SELECT COUNT(*) FROM [DropdownOptions];";
Assert.Equal(9, Convert.ToInt32(await untouchedCmd.ExecuteScalarAsync()));
}
// Re-running only the seed statements must be a no-op (idempotence).
var before = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
await ApplySeedStatementsAsync(context);
var after = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
Assert.Equal(before, after);
var migration = new SH303_ServicesRegistry();
await ApplyMigrationAsync(context, migration, "Down");
Assert.Equal(0, await CountAsync(connection, "SELECT COUNT(*) FROM sys.tables WHERE name IN ('Services', 'ServiceWorkOrderTypes');"));
await ApplyMigrationAsync(context, migration, "Up");
Assert.Equal(6, await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"));
Assert.Equal(18, await CountAsync(connection, "SELECT COUNT(*) FROM [ServiceWorkOrderTypes];"));
}
finally
{
await DropDatabaseAsync(masterConnectionString, dbName);
}
}
private static List<MigrationOperation> BuildOperations(string direction)
=> BuildOperations<SH303_ServicesRegistry>(direction);
private static List<MigrationOperation> BuildOperations<TMigration>(string direction)
where TMigration : Migration, new()
{
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(new TMigration(), [builder]);
return builder.Operations;
}
private static async Task ApplyMigrationAsync(ApplicationDbContext context, Migration migration, string direction = "Up")
{
var builder = new MigrationBuilder(context.Database.ProviderName!);
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(migration, [builder]);
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(builder.Operations, context.Model);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task ApplySeedStatementsAsync(ApplicationDbContext context)
{
var sqlOperations = BuildOperations("Up").OfType<SqlOperation>().ToList();
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(sqlOperations, context.Model);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task<int> CountAsync(SqlConnection connection, string commandText)
{
await using var command = connection.CreateCommand();
command.CommandText = commandText;
return Convert.ToInt32(await command.ExecuteScalarAsync());
}
private static async Task<string?> ResolveMasterConnectionStringAsync()
{
var configured = Environment.GetEnvironmentVariable("SH303_SQLSERVER_MASTER");
var candidate = !string.IsNullOrWhiteSpace(configured)
? WithDatabase(configured, "master")
: OperatingSystem.IsWindows() ? LocalDbMaster : null;
if (candidate == null)
return null;
try
{
await using var connection = new SqlConnection(candidate);
await connection.OpenAsync();
return candidate;
}
catch (SqlException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
}
private static async Task CreateDatabaseAsync(string masterConnectionString, string dbName)
{
await using var connection = new SqlConnection(masterConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE {QuoteSqlServerIdentifier(dbName)};";
await command.ExecuteNonQueryAsync();
}
private static async Task DropDatabaseAsync(string masterConnectionString, string dbName)
{
try
{
var quoted = QuoteSqlServerIdentifier(dbName);
await using var connection = new SqlConnection(masterConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText =
$"""
IF DB_ID(@dbName) IS NOT NULL
BEGIN
ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE {quoted};
END
""";
command.Parameters.AddWithValue("@dbName", dbName);
await command.ExecuteNonQueryAsync();
}
catch
{
// Best-effort cleanup for ephemeral SQL Server databases.
}
}
private static string QuoteSqlServerIdentifier(string name) =>
"[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
private static string WithDatabase(string connectionString, string database)
{
var builder = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = database
};
return builder.ConnectionString;
}
}