From 0248aea5425d493d17bdab66196c5c848a0ca5b8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 14 Sep 2026 22:20:46 -0300 Subject: [PATCH] fix(locations): preserve contacts when deleting sites --- .../LocationSiteContactsTests.cs | 61 ++++ .../Implementation/LocationDataService.cs | 16 +- .../SH138SiteContactsSqlServerTests.cs | 316 ++++++++++++++++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 SeaHavenIndustries.Tests/SH138SiteContactsSqlServerTests.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs index a045661..5b9cf9d 100644 --- a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs @@ -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); diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 57221c8..71be6ec 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -209,10 +209,24 @@ namespace SeaHaven.DataServices.Implementation public async Task 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()) + { + if (contact.IsDeleted != true) + { + contact.IsDeleted = true; + contact.DeletionTime = now; + } + + contact.LocationId = null; + } + _context.Locations.Remove(entity); await _context.SaveChangesAsync(cancellationToken); return true; diff --git a/SeaHavenIndustries.Tests/SH138SiteContactsSqlServerTests.cs b/SeaHavenIndustries.Tests/SH138SiteContactsSqlServerTests.cs new file mode 100644 index 0000000..e93807f --- /dev/null +++ b/SeaHavenIndustries.Tests/SH138SiteContactsSqlServerTests.cs @@ -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() + .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() + .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(); + var commands = sqlGenerator.Generate(builder.Operations, model: null); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task 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; + } +}