fix(locations): preserve contacts when deleting sites

This commit is contained in:
Alexandre Brandizzi 2026-09-14 22:20:46 -03:00
parent 4872fe5ba1
commit 0248aea542
3 changed files with 392 additions and 1 deletions

View file

@ -489,6 +489,67 @@ public class LocationSiteContactsTests
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
{
public void UpExposed(MigrationBuilder builder) => Up(builder);

View file

@ -209,10 +209,24 @@ namespace SeaHaven.DataServices.Implementation
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)
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);
await _context.SaveChangesAsync(cancellationToken);
return true;

View 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;
}
}