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 { /// Fills the fields a new site must carry (client, address, one contact) unless the test set them. private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request) { if (request.AccountId == null) { if (!ctx.Accounts.Any(a => a.Id == 7)) { ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false }); ctx.SaveChanges(); } request.AccountId = 7; } request.Address ??= "1 Depot Rd"; request.City ??= "Dallas"; request.State ??= "TX"; request.Contacts ??= new List { new() { Name = "Main", Phone = "555-0100" } }; return request; } private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .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(), Mock.Of(), new SeaHaven.Services.Implementation.TeamPermissionPolicy()); private static ClaimsPrincipal OrgWideAdmin() { var claims = new List { 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 SeedLocationWithContactsAsync(ApplicationDbContext ctx) { var service = NewService(ctx); await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Depot", Phone = "555-9000", AccountId = null, Contacts = new List { 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_KeepsSitePhoneIndependent_SetsAccountFromLocation() { using var ctx = NewContext(); ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false }); await ctx.SaveChangesAsync(); await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 7, Contacts = new List { 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().BeNull("Site Phone is independent of the contacts"); 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(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Contacts = new List() }), OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .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 { new() { Name = name, Phone = phone } }; var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Contacts = contacts }), OrgWideAdmin(), CancellationToken.None); if (expectedError == null) { await act.Should().NotThrowAsync(); return; } (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.ErrorMessage == expectedError); } [Fact] public async Task Create_RejectsOversizedNameAndPhone() { using var ctx = NewContext(); var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Contacts = new List { new() { Name = new string('x', 101), Phone = new string('5', 21) } } }), OrgWideAdmin(), CancellationToken.None); var thrown = (await act.Should().ThrowAsync()).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(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Contacts = contacts }), OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .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() }, OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts"); ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true); } [Fact] public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest() { 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 { 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().BeNull("Site Phone comes from the request, not the 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 { 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 { new() { Name = "Alice Cooper", Phone = "555-0100" }, new() { Id = 901, Name = "Foreign", Phone = "555-0900" } } }, OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .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-9000", "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 { new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" } } }, OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .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 { new() { Id = 902, Name = "AccountOnly", Phone = "555-0900" } } }, OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .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 { 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()) .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 { 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 { new() { Name = "Nope", Phone = "555-0000" } } }, caller, CancellationToken.None); await act.Should().ThrowAsync(); 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 { 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(); data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((new List { new() { Id = 1, Name = "One" }, new() { Id = 2, Name = "Two" } }, 2)); data.Setup(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync(new List { 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(), new CreateLocationValidation(), new UpdateLocationValidation(), Mock.Of(), new SeaHaven.Services.Implementation.TeamPermissionPolicy()); 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>(), It.IsAny()), Times.Once); data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.Is>(ids => ids.Contains(1) && ids.Contains(2)), It.IsAny()), 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(), CancellationToken.None); rows.Should().BeEmpty(); } 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().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(); } [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>()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.GetLocationById(seeded.Id, CancellationToken.None); var ok = result.Should().BeOfType().Subject; using var json = JsonSerializer.SerializeToDocument(ok.Value); var root = json.RootElement; root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts"); 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>()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.GetLocationList(cancellationToken: CancellationToken.None); var ok = result.Should().BeOfType().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-9000"); row.GetProperty("Contacts").GetArrayLength().Should().Be(2); } }