mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
342 lines
15 KiB
C#
342 lines
15 KiB
C#
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;
|
|
}
|
|
}
|