shoc-backend/SeaHavenIndustries.Tests/SH138SiteContactsSqlServerTests.cs
2026-09-15 19:03:54 -03:00

316 lines
13 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 SeaHaven.DataServices.Implementation;
namespace SeaHavenIndustries.Tests;
public class SH138SiteContactsSqlServerTests
{
private const string LocalDbMaster =
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
[Fact]
public async Task SH138_SiteContacts_MigrationAddsNullableIntOrderColumn_WhenLocalDbAvailable()
{
var masterConnectionString = await ResolveMasterConnectionStringAsync();
if (masterConnectionString == null)
return;
var dbName = $"SH138Migration_{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 createTables = connection.CreateCommand())
{
createTables.CommandText =
"""
CREATE TABLE Contacts (
Id int NOT NULL IDENTITY PRIMARY KEY,
FirstName nvarchar(max) NULL,
PhoneNumber nvarchar(max) NULL
);
""";
await createTables.ExecuteNonQueryAsync();
}
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await ApplyMigrationUpAsync(context, new SH138_SiteContacts());
await using (var columnCmd = connection.CreateCommand())
{
columnCmd.CommandText =
"""
SELECT t.name
FROM sys.columns c
JOIN sys.types t ON c.user_type_id = t.user_type_id
WHERE c.object_id = OBJECT_ID(N'Contacts')
AND c.name = N'SiteContactOrder';
""";
var columnType = await columnCmd.ExecuteScalarAsync();
Assert.Equal("int", columnType);
}
await using (var nullableCmd = connection.CreateCommand())
{
nullableCmd.CommandText =
"""
SELECT c.is_nullable
FROM sys.columns c
WHERE c.object_id = OBJECT_ID(N'Contacts')
AND c.name = N'SiteContactOrder';
""";
var isNullable = await nullableCmd.ExecuteScalarAsync();
Assert.Equal(true, isNullable);
}
}
finally
{
await DropDatabaseAsync(masterConnectionString, dbName);
}
}
[Fact]
public async Task SH138_LocationDelete_DetachesSoftDeletedContacts_WithRestrictFk_WhenLocalDbAvailable()
{
var masterConnectionString = await ResolveMasterConnectionStringAsync();
if (masterConnectionString == null)
return;
var dbName = $"SH138LocationDelete_{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 createTables = connection.CreateCommand())
{
createTables.CommandText =
"""
CREATE TABLE Locations (
Id int NOT NULL IDENTITY PRIMARY KEY,
AccountId int NULL,
Title nvarchar(max) NULL,
Name nvarchar(max) NULL,
Latitude nvarchar(max) NULL,
Longitude nvarchar(max) NULL,
Address1 nvarchar(max) NULL,
Address2 nvarchar(max) NULL,
City nvarchar(max) NULL,
State nvarchar(max) NULL,
Zip nvarchar(max) NULL,
PhoneNumber nvarchar(max) NULL,
Email nvarchar(max) NULL,
Status nvarchar(max) NULL,
ExternalSource nvarchar(max) NULL,
ExternalLocationId nvarchar(max) NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL
);
CREATE TABLE Contacts (
Id int NOT NULL IDENTITY PRIMARY KEY,
AccountId int NULL,
LocationId int NULL,
Title nvarchar(max) NULL,
Owner nvarchar(max) NULL,
FirstName nvarchar(max) NULL,
MiddleName nvarchar(max) NULL,
LastName nvarchar(max) NULL,
ContactType nvarchar(max) NULL,
PhoneNumber nvarchar(max) NULL,
Email nvarchar(max) NULL,
Address1 nvarchar(max) NULL,
Address2 nvarchar(max) NULL,
City nvarchar(max) NULL,
State nvarchar(max) NULL,
Zip nvarchar(max) NULL,
FacebookUrl nvarchar(max) NULL,
LinkedInUrl nvarchar(max) NULL,
TwitterUrl nvarchar(max) NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL,
SiteContactOrder int NULL,
CONSTRAINT FK_Contacts_Locations_LocationId FOREIGN KEY (LocationId) REFERENCES Locations (Id) ON DELETE NO ACTION
);
SET IDENTITY_INSERT Locations ON;
INSERT INTO Locations (Id, Name) VALUES (11, 'Depot');
SET IDENTITY_INSERT Locations OFF;
INSERT INTO Contacts (LocationId, FirstName, PhoneNumber, SiteContactOrder) VALUES (11, 'Alice Cooper', '555-0100', 0);
INSERT INTO Contacts (LocationId, FirstName, PhoneNumber, IsDeleted, DeleterUserId, DeletionTime) VALUES (11, 'Bob Dillon', '555-0200', 1, 'actor-1', '2026-01-01T00:00:00');
""";
await createTables.ExecuteNonQueryAsync();
}
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(connection)
.Options;
await using var context = new ApplicationDbContext(options);
var service = new LocationDataService(context);
var deleted = await service.DeleteByIdAsync(11, CancellationToken.None);
Assert.True(deleted);
await using (var locationCmd = connection.CreateCommand())
{
locationCmd.CommandText = "SELECT COUNT(*) FROM Locations;";
var locationCount = await locationCmd.ExecuteScalarAsync();
Assert.Equal(0, Convert.ToInt32(locationCount));
}
await using (var activeCmd = connection.CreateCommand())
{
activeCmd.CommandText =
"""
SELECT FirstName, PhoneNumber, LocationId, IsDeleted, DeleterUserId, DeletionTime
FROM Contacts
WHERE FirstName = N'Alice Cooper';
""";
await using var reader = await activeCmd.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal("Alice Cooper", reader.GetString(0));
Assert.Equal("555-0100", reader.GetString(1));
Assert.True(reader.IsDBNull(2));
Assert.True(reader.GetBoolean(3));
Assert.True(reader.IsDBNull(4), "delete carries no audit actor");
Assert.False(reader.IsDBNull(5));
Assert.False(await reader.ReadAsync());
}
await using (var previouslyDeletedCmd = connection.CreateCommand())
{
previouslyDeletedCmd.CommandText =
"""
SELECT LocationId, IsDeleted, DeleterUserId, DeletionTime
FROM Contacts
WHERE FirstName = N'Bob Dillon';
""";
await using var reader = await previouslyDeletedCmd.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.True(reader.IsDBNull(0), "already soft-deleted rows detach so the restrict FK cannot block the delete");
Assert.True(reader.GetBoolean(1));
Assert.Equal("actor-1", reader.GetString(2));
Assert.Equal(new DateTime(2026, 1, 1), reader.GetDateTime(3));
Assert.False(await reader.ReadAsync());
}
}
finally
{
await DropDatabaseAsync(masterConnectionString, dbName);
}
}
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
{
var builder = new MigrationBuilder(context.Database.ProviderName!);
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Migration.Up not found.");
up.Invoke(migration, [builder]);
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(builder.Operations, model: null);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task<string?> ResolveMasterConnectionStringAsync()
{
var configured = Environment.GetEnvironmentVariable("SH138_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)
{
var quoted = QuoteSqlServerIdentifier(dbName);
await using var connection = new SqlConnection(masterConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE {quoted};";
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 LocalDB 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;
}
}