mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
316 lines
13 KiB
C#
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;
|
|
}
|
|
}
|