mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +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();
|
||||
}
|
||||
|
||||
[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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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