mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 09:19:34 +00:00
fix(locations): preserve contacts when deleting sites
This commit is contained in:
parent
4872fe5ba1
commit
0248aea542
3 changed files with 392 additions and 1 deletions
|
|
@ -489,6 +489,67 @@ public class LocationSiteContactsTests
|
||||||
rows.Should().BeEmpty();
|
rows.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DataService_DeleteByIdAsync_WithContacts_RemovesLocation_RetainsSoftDeletedDetachedContacts()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
||||||
|
var alice = seeded.Contacts!.Single(c => c.FirstName == "Alice Cooper");
|
||||||
|
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
|
||||||
|
|
||||||
|
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
deleted.Should().BeTrue();
|
||||||
|
ctx.Locations.Should().BeEmpty();
|
||||||
|
|
||||||
|
var retained = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
|
||||||
|
retained.Should().HaveCount(2);
|
||||||
|
retained.Should().OnlyContain(c => c.IsDeleted == true);
|
||||||
|
retained.Should().OnlyContain(c => c.LocationId == null);
|
||||||
|
retained.Should().OnlyContain(c => c.DeletionTime != null);
|
||||||
|
retained.Should().OnlyContain(c => c.DeleterUserId == null, "the delete interface carries no actor");
|
||||||
|
retained.Single(c => c.Id == alice.Id).FirstName.Should().Be("Alice Cooper");
|
||||||
|
retained.Single(c => c.Id == alice.Id).PhoneNumber.Should().Be("555-0100");
|
||||||
|
retained.Single(c => c.Id == bob.Id).FirstName.Should().Be("Bob Dillon");
|
||||||
|
retained.Single(c => c.Id == bob.Id).PhoneNumber.Should().Be("555-0200");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DataService_DeleteByIdAsync_AlreadySoftDeletedContact_IsDetachedWithoutAuditRestamp()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
||||||
|
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
|
||||||
|
bob.IsDeleted = true;
|
||||||
|
bob.DeleterUserId = "admin-1";
|
||||||
|
bob.DeletionTime = new DateTime(2026, 1, 1);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
var bobStamp = bob.DeletionTime;
|
||||||
|
|
||||||
|
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
deleted.Should().BeTrue();
|
||||||
|
ctx.Locations.Should().BeEmpty();
|
||||||
|
var retained = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
|
||||||
|
retained.LocationId.Should().BeNull("previously soft-deleted rows must also detach or the restrict FK blocks the delete");
|
||||||
|
retained.IsDeleted.Should().BeTrue();
|
||||||
|
retained.DeleterUserId.Should().Be("admin-1", "original audit stamp is preserved");
|
||||||
|
retained.DeletionTime.Should().Be(bobStamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DataService_DeleteByIdAsync_Missing_ReturnsFalse()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
await SeedLocationWithContactsAsync(ctx);
|
||||||
|
|
||||||
|
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(424242, CancellationToken.None);
|
||||||
|
|
||||||
|
deleted.Should().BeFalse();
|
||||||
|
ctx.Locations.Should().HaveCount(1);
|
||||||
|
ctx.Contacts.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
|
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
|
||||||
{
|
{
|
||||||
public void UpExposed(MigrationBuilder builder) => Up(builder);
|
public void UpExposed(MigrationBuilder builder) => Up(builder);
|
||||||
|
|
|
||||||
|
|
@ -209,10 +209,24 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entity = await _context.Locations.FindAsync(new object[] { id }, cancellationToken);
|
var entity = await _context.Locations
|
||||||
|
.Include(l => l.Contacts)
|
||||||
|
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
foreach (var contact in entity.Contacts ?? Enumerable.Empty<Contacts>())
|
||||||
|
{
|
||||||
|
if (contact.IsDeleted != true)
|
||||||
|
{
|
||||||
|
contact.IsDeleted = true;
|
||||||
|
contact.DeletionTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
contact.LocationId = null;
|
||||||
|
}
|
||||||
|
|
||||||
_context.Locations.Remove(entity);
|
_context.Locations.Remove(entity);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
316
SeaHavenIndustries.Tests/SH138SiteContactsSqlServerTests.cs
Normal file
316
SeaHavenIndustries.Tests/SH138SiteContactsSqlServerTests.cs
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue