Merge pull request #118 from Sea-Haven-Industries/feat/ab/sh-138-site-contacts
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions

SH-138: manage ordered site contacts
This commit is contained in:
Alexandre Brandizzi 2026-09-16 10:47:23 -03:00 • committed by GitHub
commit d07ac42452
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 5297 additions and 30 deletions

View file

@ -0,0 +1,642 @@
using System.Security.Claims;
using System.Text.Json;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationSiteContactsTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx) =>
new(
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation());
private static ClaimsPrincipal OrgWideAdmin()
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "admin-1"),
new(ClaimTypes.Role, "Admin"),
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
{
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Depot",
AccountId = null,
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200"}
}
}, OrgWideAdmin(), CancellationToken.None);
return ctx.Locations.Include(l => l.Contacts).Single();
}
[Fact]
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_SetsAccountFromLocation()
{
using var ctx = NewContext();
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
await ctx.SaveChangesAsync();
await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
AccountId = 7,
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200" }
}
}, OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber");
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
contacts.Should().HaveCount(2);
contacts[0].FirstName.Should().Be("Alice Cooper");
contacts[0].MiddleName.Should().BeNull();
contacts[0].LastName.Should().BeNull();
contacts[0].PhoneNumber.Should().Be("555-0100");
contacts[0].SiteContactOrder.Should().Be(0);
contacts[0].AccountId.Should().Be(7, "AccountId must come from the location");
contacts[0].LocationId.Should().Be(location.Id);
contacts[0].createdby.Should().Be("admin-1");
contacts[0].CreatedDate.Should().NotBeNull();
contacts[1].SiteContactOrder.Should().Be(1);
}
[Fact]
public async Task Create_EmptyContactsArray_IsExplicitValidationError_AndWritesNothing()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>()
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
ctx.Locations.Should().BeEmpty();
ctx.Contacts.Should().BeEmpty();
}
[Theory]
[InlineData(null, "555-0100", "Contact name is required.")]
[InlineData(" ", "555-0100", "Contact name is required.")]
[InlineData("Alice", null, "Contact phone is required.")]
[InlineData("Alice", " ", "Contact phone is required.")]
[InlineData("Alice", "555-0100", null)]
public async Task Create_ValidatesEachRow(string? name, string? phone, string? expectedError)
{
using var ctx = NewContext();
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
if (expectedError == null)
{
await act.Should().NotThrowAsync();
return;
}
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().Contain(e => e.ErrorMessage == expectedError);
}
[Fact]
public async Task Create_RejectsOversizedNameAndPhone()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = new string('x', 101), Phone = new string('5', 21) }
}
}, OrgWideAdmin(), CancellationToken.None);
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).Which;
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100"));
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 20"));
}
[Fact]
public async Task Create_RejectsMoreThanTwentyContacts()
{
using var ctx = NewContext();
var contacts = Enumerable.Range(1, 21)
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
.ToList();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20"));
}
[Fact]
public async Task Update_ContactsNull_KeepsLegacyBehavior_AndDoesNotMutateContactRows()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var before = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Phone = "555-9999"
}, OrgWideAdmin(), CancellationToken.None);
var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
after.Should().HaveCount(before.Count);
after[0].FirstName.Should().Be(before[0].FirstName);
after[0].PhoneNumber.Should().Be(before[0].PhoneNumber);
after[0].SiteContactOrder.Should().Be(before[0].SiteContactOrder);
after[0].IsDeleted.Should().Be(before[0].IsDeleted);
ctx.Locations.Single().PhoneNumber.Should().Be("555-9999", "legacy updates keep request.Phone");
}
[Fact]
public async Task Update_EmptyContactsArray_IsExplicitValidationError()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>()
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true);
}
[Fact]
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
{
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");
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>
{
new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" },
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0300" },
new() { Name = "Cara Lane", Phone = "555-0400" }
}
}, OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact");
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
active.Should().HaveCount(3);
active.Select(c => c.FirstName).Should().ContainInOrder("Bob Dillon", "Alice Cooper", "Cara Lane");
active.Select(c => c.SiteContactOrder).Should().ContainInOrder(new int?[] { 0, 1, 2 }, "order is densified from zero");
active[1].PhoneNumber.Should().Be("555-0300");
active[0].Id.Should().Be(bob.Id);
active[1].Id.Should().Be(alice.Id, "existing ids are preserved");
active[2].AccountId.Should().Be(location.AccountId);
}
[Fact]
public async Task Update_SoftDeletesRemovedContacts_WithAuditFields_KeepingRowsReadable()
{
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");
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } }
}, OrgWideAdmin(), CancellationToken.None);
var removed = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
removed.IsDeleted.Should().BeTrue();
removed.DeleterUserId.Should().Be("admin-1");
removed.DeletionTime.Should().NotBeNull();
removed.LastModificationTime.Should().NotBeNull();
removed.FirstName.Should().Be("Bob Dillon", "soft deleted rows keep data so historical WorkOrderContacts still render");
removed.PhoneNumber.Should().Be("555-0200");
var kept = ctx.Contacts.AsNoTracking().Single(c => c.Id == alice.Id);
kept.IsDeleted.Should().NotBeTrue();
}
[Fact]
public async Task Update_ForeignLocationContactId_RejectedWithoutMutation()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
ctx.Locations.Add(new Locations { Id = seeded.Id + 1, Name = "Other" });
ctx.Contacts.Add(new Contacts { Id = 901, LocationId = seeded.Id + 1, FirstName = "Foreign", PhoneNumber = "555-0900" });
await ctx.SaveChangesAsync();
var snapshot = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = "Alice Cooper", Phone = "555-0100" },
new() { Id = 901, Name = "Foreign", Phone = "555-0900" }
}
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
after.Should().HaveCount(snapshot.Count);
after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location));
ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber
.Should().Be("555-0100", "the rejected update must not persist any change");
}
[Fact]
public async Task Update_DeletedContactId_Rejected()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon");
bob.IsDeleted = true;
await ctx.SaveChangesAsync();
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" } }
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
}
[Fact]
public async Task Update_AccountLevelContactId_Rejected()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
ctx.Contacts.Add(new Contacts { Id = 902, AccountId = 55, LocationId = null, FirstName = "AccountOnly", PhoneNumber = "555-0900" });
await ctx.SaveChangesAsync();
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = 902, Name = "AccountOnly", Phone = "555-0900" } }
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
}
[Fact]
public async Task Update_DuplicateContactIds_Rejected()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper");
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>
{
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" },
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" }
}
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("more than once"));
}
[Fact]
public async Task Update_AccountScopedCaller_CannotAttachContactsToForeignLocation()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
seeded.AccountId = 99;
await ctx.SaveChangesAsync();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, "Dispatcher"),
new(SeaHavenClaimTypes.AccountId, "4")
};
var caller = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Name = "Nope", Phone = "555-0000" } }
}, caller, CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true);
}
[Fact]
public async Task Detail_ExcludesDeletedContacts_AndOrdersBySiteContactOrderThenId()
{
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");
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } }
}, OrgWideAdmin(), CancellationToken.None);
var detail = await NewService(ctx).GetLocationDetailAsync(seeded.Id, CancellationToken.None);
detail!.Contacts.Should().ContainSingle().Which.Id.Should().Be(alice.Id);
}
[Fact]
public async Task Detail_OrdersContactsByOrderThenId_WhenOrdersAreMissing()
{
using var ctx = NewContext();
var loc = new Locations { Id = 5, Name = "Legacy", AccountId = 7 };
ctx.Locations.Add(loc);
ctx.Contacts.AddRange(
new Contacts { Id = 31, LocationId = 5, AccountId = 7, FirstName = "No Order B", PhoneNumber = "555-2" },
new Contacts { Id = 30, LocationId = 5, AccountId = 7, FirstName = "No Order A", PhoneNumber = "555-1" });
await ctx.SaveChangesAsync();
var detail = await NewService(ctx).GetLocationDetailAsync(5, CancellationToken.None);
detail!.Contacts!.Select(c => c.Name).Should().ContainInOrder("No Order A", "No Order B");
}
[Fact]
public async Task List_LoadsPageContactsInSingleBatchedRead()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<Locations>
{
new() { Id = 1, Name = "One" },
new() { Id = 2, Name = "Two" }
}, 2));
data.Setup(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Contacts>
{
new() { Id = 10, LocationId = 1, SiteContactOrder = 1, FirstName = "Second", PhoneNumber = "555-2" },
new() { Id = 11, LocationId = 1, SiteContactOrder = 0, FirstName = "First", PhoneNumber = "555-1" },
new() { Id = 12, LocationId = 2, SiteContactOrder = 0, FirstName = "Solo", PhoneNumber = "555-3" }
});
var service = new LocationService(
data.Object,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation());
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
page.Items.Should().HaveCount(2);
page.Items.Single(i => i.Id == 1).Contacts!.Select(c => c.Name)
.Should().ContainInOrder(new[] { "First", "Second" }, "contacts are ordered per location");
page.Items.Single(i => i.Id == 2).Contacts!.Select(c => c.Name).Should().ContainSingle("Solo");
data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.Is<IReadOnlyCollection<int>>(ids => ids.Contains(1) && ids.Contains(2)), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DataService_GetSiteContactsByLocationIdsAsync_ExcludesDeleted_AndOrders()
{
using var ctx = NewContext();
ctx.Contacts.AddRange(
new Contacts { Id = 1, LocationId = 5, SiteContactOrder = 1, FirstName = "B" },
new Contacts { Id = 2, LocationId = 5, SiteContactOrder = 0, FirstName = "A" },
new Contacts { Id = 3, LocationId = 5, SiteContactOrder = 0, FirstName = "Tie", IsDeleted = true },
new Contacts { Id = 4, LocationId = 6, SiteContactOrder = 0, FirstName = "Other" });
await ctx.SaveChangesAsync();
var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(new[] { 5 }, CancellationToken.None);
rows.Select(r => r.FirstName).Should().ContainInOrder("A", "B");
rows.Should().OnlyContain(r => r.IsDeleted != true);
}
[Fact]
public async Task DataService_GetSiteContactsByLocationIdsAsync_EmptyInput_ReturnsWithoutQuerying()
{
using var ctx = NewContext();
ctx.Contacts.Add(new Contacts { Id = 1, LocationId = 5, FirstName = "A" });
await ctx.SaveChangesAsync();
var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(Array.Empty<int>(), CancellationToken.None);
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);
public void DownExposed(MigrationBuilder builder) => Down(builder);
}
[Fact]
public void Migration_SH138_IsAdditiveOnly()
{
var migration = new ExposedSH138Migration();
var up = new MigrationBuilder("SqlServer");
migration.UpExposed(up);
up.Operations.Should().ContainSingle("the migration must only add the SiteContactOrder column");
var column = up.Operations.Single().Should().BeOfType<AddColumnOperation>().Subject;
column.Table.Should().Be("Contacts");
column.Name.Should().Be("SiteContactOrder");
column.IsNullable.Should().BeTrue("existing rows and legacy contacts have no order");
column.ClrType.Should().Be(typeof(int));
var down = new MigrationBuilder("SqlServer");
migration.DownExposed(down);
down.Operations.Should().ContainSingle("reverting drops only the added column");
down.Operations.Single().Should().BeOfType<DropColumnOperation>();
}
[Fact]
public void Migration_SH138_HasNoReorderIndexOrFkChange()
{
var migration = new ExposedSH138Migration();
var up = new MigrationBuilder("SqlServer");
migration.UpExposed(up);
up.Operations.Should().NotContain(o => o is CreateIndexOperation, "no unique reorder index");
up.Operations.Should().NotContain(o => o is AddForeignKeyOperation || o is DropForeignKeyOperation, "the existing restrict relationship is unchanged");
up.Operations.Should().NotContain(o => o is DropColumnOperation || o is DropTableOperation || o is SqlOperation, "no destructive or backfill operations");
}
[Fact]
public async Task Controller_Detail_ProjectsContactsAndCompatibilityFields()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var controller = new LocationController(
NewService(ctx),
Mock.Of<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.GetLocationById(seeded.Id, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
using var json = JsonSerializer.SerializeToDocument(ok.Value);
var root = json.RootElement;
root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact");
root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name");
var contacts = root.GetProperty("Contacts");
contacts.GetArrayLength().Should().Be(2);
contacts[0].GetProperty("Name").GetString().Should().Be("Alice Cooper");
contacts[0].GetProperty("Phone").GetString().Should().Be("555-0100");
contacts[1].GetProperty("Name").GetString().Should().Be("Bob Dillon");
}
[Fact]
public async Task Controller_List_ProjectsFirstContactAsLegacyContactField()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var controller = new LocationController(
NewService(ctx),
Mock.Of<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.GetLocationList(cancellationToken: CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
using var json = JsonSerializer.SerializeToDocument(ok.Value);
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
row.GetProperty("Contact").GetString().Should().Be("Alice Cooper");
row.GetProperty("Phone").GetString().Should().Be("555-0100");
row.GetProperty("Contacts").GetArrayLength().Should().Be(2);
}
}

View file

@ -54,10 +54,11 @@ namespace Api.SeaHavenIndustries.Controllers
State = l.State,
ZipCode = l.Zip,
Phone = l.PhoneNumber,
Contact = (string?)null,
Contact = l.Contacts?.FirstOrDefault()?.Name,
ContactEmail = l.Email,
Status = l.Status,
AccountId = l.AccountId
AccountId = l.AccountId,
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
});
var viewModel = new Pagination_DTO
@ -98,10 +99,11 @@ namespace Api.SeaHavenIndustries.Controllers
location.State,
ZipCode = location.Zip,
Phone = location.PhoneNumber,
Contact = (string?)null,
Contact = location.Contacts?.FirstOrDefault()?.Name,
ContactEmail = location.Email,
location.Status,
location.AccountId
location.AccountId,
Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
};
return Ok(result);
@ -197,7 +199,8 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = model.Phone,
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId()
AccountId = model.GetAccountId(),
Contacts = MapSiteContacts(model.Contacts)
};
}
@ -214,8 +217,19 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = model.Phone,
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId()
AccountId = model.GetAccountId(),
Contacts = MapSiteContacts(model.Contacts)
};
}
private static List<SiteContactRequestDTO>? MapSiteContacts(List<SiteContactInput_DTO>? contacts)
{
if (contacts == null)
return null;
return contacts
.Select(c => new SiteContactRequestDTO { Id = c.Id, Name = c.Name, Phone = c.Phone })
.ToList();
}
}
}

View file

@ -16,6 +16,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -3,6 +3,14 @@ using SeaHaven.Services.DTOs;
namespace Api.SeaHavenIndustries.DTOs
{
// SH-138: additive site-contact write row (id optional on writes)
public class SiteContactInput_DTO
{
public int? Id { get; set; }
public string? Name { get; set; }
public string? Phone { get; set; }
}
public class Location_DTO
{
public string? Title { get; set; }
@ -16,6 +24,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH138_SiteContacts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SiteContactOrder",
table: "Contacts",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SiteContactOrder",
table: "Contacts");
}
}
}

View file

@ -651,6 +651,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SiteContactOrder")
.HasColumnType("int");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@ -2400,6 +2403,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("AdditionalContacts")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("AfterPhotoAttachment")
.HasColumnType("nvarchar(max)");
@ -2548,10 +2555,6 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("AdditionalContacts")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
@ -3224,16 +3227,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
@ -3294,7 +3287,7 @@ namespace Data.SeaHavenIndustries.Migrations
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
.WithMany()
.WithMany("Contacts")
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Restrict);
@ -3474,6 +3467,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
{
b.HasOne("Data.SeaHavenIndustries.Assets", "Asset")
@ -3869,7 +3872,7 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.Navigation("Account");
b.Navigation("Contacts");
b.Navigation("Templates");

View file

@ -30,6 +30,9 @@ namespace Data.SeaHavenIndustries
public int? LocationId { get; set; }
public int? AccountId { get; set; }
// SH-138: dense zero-based display order for site-managed contacts
public int? SiteContactOrder { get; set; }
// Navigation Properties
[ForeignKey(nameof(LocationId))]
public Locations? Location { get; set; }

View file

@ -30,5 +30,8 @@ namespace Data.SeaHavenIndustries
// Navigation Properties
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? workOrders { get; set; }
// SH-138: site contacts (soft-deleted rows stay for historical WorkOrderContacts)
public ICollection<Contacts>? Contacts { get; set; }
}
}

View file

@ -166,12 +166,30 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Include(l => l.Contacts.Where(c => c.IsDeleted != true))
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations.FindAsync(new object[] { id }, cancellationToken);
return await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
IReadOnlyCollection<int> locationIds,
CancellationToken cancellationToken)
{
if (locationIds.Count == 0)
return Array.Empty<Contacts>();
return await _context.Contacts
.AsNoTracking()
.Where(c => c.LocationId != null && locationIds.Contains(c.LocationId.Value) && c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.ToListAsync(cancellationToken);
}
public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
@ -191,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

@ -37,6 +37,15 @@ namespace SeaHaven.DataServices.Interfaces
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
/// <summary>
/// SH-138: single bounded read of active site contacts for a page of locations,
/// ordered by SiteContactOrder then Id. Deleted contacts are excluded.
/// </summary>
Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
IReadOnlyCollection<int> locationIds,
CancellationToken cancellationToken);
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken);

View file

@ -16,6 +16,25 @@ namespace SeaHaven.Services.DTOs
public int? AccountId { get; set; }
public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; }
/// <summary>SH-138: ordered site contacts; null when the site has none.</summary>
public IReadOnlyList<SiteContactResponseDTO>? Contacts { get; set; }
}
/// <summary>SH-138: additive site-contact write row. Id is optional on writes.</summary>
public class SiteContactRequestDTO
{
public int? Id { get; set; }
public string? Name { get; set; }
public string? Phone { get; set; }
}
/// <summary>SH-138: site-contact read row, ordered by SiteContactOrder then Id.</summary>
public class SiteContactResponseDTO
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Phone { get; set; }
}
public class CreateLocationDTO
@ -50,6 +69,9 @@ namespace SeaHaven.Services.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
/// <summary>SH-138: null keeps legacy behavior; empty array is a validation error.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
}
public class LocationUpdateRequestDTO
@ -64,6 +86,9 @@ namespace SeaHaven.Services.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
/// <summary>SH-138: null keeps legacy behavior and must not mutate contact rows.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
}
public class SiteOptionDTO

View file

@ -49,13 +49,13 @@ namespace SeaHaven.Services.Implementation
public async Task<IEnumerable<LocationDTO>> GetAllLocationsAsync()
{
var locations = await _locationDataService.GetAllAsync();
return locations.Select(MapToDTO);
return locations.Select(l => MapToDTO(l));
}
public async Task<IEnumerable<LocationDTO>> GetLocationsByAccountIdAsync(int accountId)
{
var locations = await _locationDataService.GetByAccountIdAsync(accountId);
return locations.Select(MapToDTO);
return locations.Select(l => MapToDTO(l));
}
public async Task<PagedResult<LocationDTO>> GetLocationsPagedAsync(int page, int pageSize, string? search = null)
@ -63,7 +63,7 @@ namespace SeaHaven.Services.Implementation
var (items, totalCount) = await _locationDataService.GetPagedAsync(page, pageSize, search);
return new PagedResult<LocationDTO>
{
Items = items.Select(MapToDTO).ToList(),
Items = items.Select(l => MapToDTO(l)).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
@ -171,9 +171,13 @@ namespace SeaHaven.Services.Implementation
var stateFilter = NormalizeStateFilter(states);
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
// SH-138: one bounded contact read for the whole page, never per row.
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
return new PagedResult<LocationDTO>
{
Items = items.Select(MapToDTO).ToList(),
Items = items.Select(l => MapToDTO(l, contactsByLocation)).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
@ -233,6 +237,11 @@ namespace SeaHaven.Services.Implementation
AccountId = request.AccountId
};
if (request.Contacts is List<SiteContactRequestDTO> contacts)
{
ApplySiteContactsForCreate(location, contacts, GetActorId(user));
}
await _locationDataService.AddAsync(location, cancellationToken);
}
@ -264,6 +273,12 @@ namespace SeaHaven.Services.Implementation
location.AccountId = accountId;
}
// SH-138: null contacts keeps legacy behavior and must not mutate contact rows.
if (request.Contacts is List<SiteContactRequestDTO> contacts)
{
ApplySiteContactsForUpdate(location, contacts, GetActorId(user));
}
await _locationDataService.UpdateAsync(location, cancellationToken);
}
@ -326,7 +341,153 @@ namespace SeaHaven.Services.Implementation
}
// Private helper methods
private LocationDTO MapToDTO(Locations location)
private static string? GetActorId(ClaimsPrincipal user) =>
user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
private static void ThrowOnInvalidContacts(List<SiteContactRequestDTO> contacts)
{
var failures = SiteContactsValidation.Validate(contacts);
if (failures.Count > 0)
throw new ValidationException(failures);
}
private static void ApplySiteContactsForCreate(
Locations location,
List<SiteContactRequestDTO> contacts,
string? actorId)
{
ThrowOnInvalidContacts(contacts);
var now = DateTime.Now;
var siteContacts = contacts
.Select((row, index) => new Contacts
{
FirstName = row.Name!.Trim(),
MiddleName = null,
LastName = null,
PhoneNumber = row.Phone!.Trim(),
SiteContactOrder = index,
AccountId = location.AccountId,
CreatedDate = now,
createdby = actorId
})
.ToList();
location.Contacts = siteContacts;
// The first site contact mirrors Location.PhoneNumber for legacy consumers.
location.PhoneNumber = siteContacts[0].PhoneNumber;
}
private static void ApplySiteContactsForUpdate(
Locations location,
List<SiteContactRequestDTO> contacts,
string? actorId)
{
ThrowOnInvalidContacts(contacts);
var existingById = (location.Contacts ?? Enumerable.Empty<Contacts>())
.ToDictionary(c => c.Id);
var keptIds = new HashSet<int>();
for (var i = 0; i < contacts.Count; i++)
{
var row = contacts[i];
var name = row.Name!.Trim();
var phone = row.Phone!.Trim();
if (row.Id is int id)
{
if (!existingById.TryGetValue(id, out var existing)
|| existing.LocationId != location.Id
|| existing.IsDeleted == true)
{
throw new ValidationException(new[]
{
new ValidationFailure(
$"{nameof(LocationUpdateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Id)}",
$"Contact {id} does not exist, is deleted, or does not belong to this location.")
});
}
keptIds.Add(id);
existing.FirstName = name;
existing.MiddleName = null;
existing.LastName = null;
existing.PhoneNumber = phone;
existing.SiteContactOrder = i;
existing.AccountId = location.AccountId;
existing.LastModificationTime = DateTime.Now;
}
else
{
location.Contacts ??= new List<Contacts>();
location.Contacts.Add(new Contacts
{
FirstName = name,
MiddleName = null,
LastName = null,
PhoneNumber = phone,
SiteContactOrder = i,
AccountId = location.AccountId,
CreatedDate = DateTime.Now,
createdby = actorId
});
}
}
// Rows omitted from the request are soft deleted so historical
// WorkOrderContacts keep rendering.
var now = DateTime.Now;
foreach (var existing in existingById.Values)
{
if (keptIds.Contains(existing.Id) || existing.IsDeleted == true)
continue;
existing.IsDeleted = true;
existing.DeleterUserId = actorId;
existing.DeletionTime = now;
existing.LastModificationTime = now;
}
// The first site contact mirrors Location.PhoneNumber for legacy consumers.
location.PhoneNumber = contacts[0].Phone!.Trim();
}
private async Task<IReadOnlyDictionary<int, IReadOnlyList<Contacts>>> GetSiteContactsByLocationIdsAsync(
List<Locations> items,
CancellationToken cancellationToken)
{
if (items.Count == 0)
return new Dictionary<int, IReadOnlyList<Contacts>>();
var rows = await _locationDataService.GetSiteContactsByLocationIdsAsync(
items.Select(l => l.Id).ToList(),
cancellationToken);
return rows
.GroupBy(c => c.LocationId!.Value)
.ToDictionary(g => g.Key, g => (IReadOnlyList<Contacts>)g.ToList());
}
private static IReadOnlyList<SiteContactResponseDTO> MapSiteContacts(IEnumerable<Contacts>? contacts)
{
if (contacts == null)
return new List<SiteContactResponseDTO>();
return contacts
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => new SiteContactResponseDTO
{
Id = c.Id,
Name = c.FirstName,
Phone = c.PhoneNumber
})
.ToList();
}
private LocationDTO MapToDTO(Locations location, IReadOnlyDictionary<int, IReadOnlyList<Contacts>>? contactsByLocation = null)
{
return new LocationDTO
{
@ -343,7 +504,10 @@ namespace SeaHaven.Services.Implementation
Status = location.Status,
AccountId = location.AccountId,
CreatedDate = location.CreatedDate,
CreatedBy = location.createdby
CreatedBy = location.createdby,
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)
? MapSiteContacts(contacts)
: MapSiteContacts(location.Contacts?.Where(c => c.IsDeleted != true))
};
}
}

View file

@ -0,0 +1,81 @@
using FluentValidation.Results;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Validation
{
/// <summary>
/// SH-138 site-contact request validation shared by location create and update.
/// Null contacts means legacy behavior (no validation, no mutation);
/// a present array must contain 1..20 complete rows.
/// </summary>
public static class SiteContactsValidation
{
public const int MaxContacts = 20;
public const int MaxNameLength = 100;
public const int MaxPhoneLength = 20;
public static List<ValidationFailure> Validate(IReadOnlyList<SiteContactRequestDTO> contacts)
{
var failures = new List<ValidationFailure>();
if (contacts.Count == 0)
{
failures.Add(new ValidationFailure(
nameof(LocationCreateRequestDTO.Contacts),
"Contacts must contain at least one contact when provided."));
return failures;
}
if (contacts.Count > MaxContacts)
{
failures.Add(new ValidationFailure(
nameof(LocationCreateRequestDTO.Contacts),
$"Contacts cannot exceed {MaxContacts} entries."));
return failures;
}
var seenIds = new HashSet<int>();
for (var i = 0; i < contacts.Count; i++)
{
var row = contacts[i];
var name = row.Name?.Trim();
var phone = row.Phone?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Name)}",
"Contact name is required."));
}
else if (name.Length > MaxNameLength)
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Name)}",
$"Contact name cannot exceed {MaxNameLength} characters."));
}
if (string.IsNullOrWhiteSpace(phone))
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Phone)}",
"Contact phone is required."));
}
else if (phone.Length > MaxPhoneLength)
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Phone)}",
$"Contact phone cannot exceed {MaxPhoneLength} characters."));
}
if (row.Id is int id && !seenIds.Add(id))
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Id)}",
$"Contact id {id} appears more than once."));
}
}
return failures;
}
}
}

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

View file

@ -841,6 +841,10 @@ public class WorkOrderAccountScopeTests
=> _inner.GetDetailByIdAsync(id, cancellationToken);
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
=> _inner.GetByIdForUpdateAsync(id, cancellationToken);
public Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
IReadOnlyCollection<int> locationIds,
CancellationToken cancellationToken)
=> _inner.GetSiteContactsByLocationIdsAsync(locationIds, cancellationToken);
public Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
=> _inner.AddAsync(location, cancellationToken);
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)