using System.Security.Claims; using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Moq; using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Validation; using Xunit; namespace Api.SeaHavenIndustries.Tests; /// /// Site registry rules: tenant-scoped unique site codes, immutable codes, /// permission-gated tombstone delete, open work order lookup and the /// contact/notes write used by the work-order Site dialog. /// public class SiteRegistryServiceTests { private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static LocationService NewService(ApplicationDbContext ctx, string? role = "Admin") => NewService(new LocationDataService(ctx), new AccountDataService(ctx), role); private static LocationService NewService( ILocationDataService data, IAccountDataService accounts, string? role = "Admin") { var permissions = new Mock(); permissions .Setup(p => p.GetUserAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((string userId, CancellationToken _) => role == null ? null : new TeamPermissionUserData { UserId = userId, RoleName = role }); return new LocationService( data, accounts, new CreateLocationValidation(), new UpdateLocationValidation(), permissions.Object, new TeamPermissionPolicy()); } private static ClaimsPrincipal OrgWide(string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)); private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString())); private static ClaimsPrincipal Principal(string role, Claim scope) => new(new ClaimsIdentity(new List { new(ClaimTypes.NameIdentifier, "actor-1"), new(ClaimTypes.Role, role), scope }, "test")); private static void SeedAccounts(ApplicationDbContext ctx, params int[] ids) { foreach (var id in ids) ctx.Accounts.Add(new Accounts { Id = id, Name = $"Client {id}", IsDeleted = false }); ctx.SaveChanges(); } private static Locations SeedSite(ApplicationDbContext ctx, string code, int? accountId, bool deleted = false) { var site = new Locations { Name = code, AccountId = accountId, City = "Dallas", State = "TX", IsDeleted = deleted ? true : null }; ctx.Locations.Add(site); ctx.SaveChanges(); return site; } private static WorkOrder Wo( int id, int? locationId, LifecycleStatus? status = LifecycleStatus.Incomplete, int? accountId = 1, string? siteCode = null, string? legacyStatus = null, bool deleted = false) => new() { Id = id, LocationId = locationId, LifecycleStatus = status, LegacyStatus = legacyStatus, AccountId = accountId, SiteCode = siteCode, IsDeleted = deleted ? true : null }; private static LocationCreateRequestDTO CreateRequest(string code, int? accountId) => new() { Name = code, AccountId = accountId, Address = "1 Depot Rd", City = "Dallas", State = "TX", Contacts = new List { new() { Name = "Main", Phone = "555-0100" } } }; [Fact] public async Task Create_DuplicateSiteCodeInSameClient_IsRejectedCaseInsensitively() { using var ctx = NewContext(); SeedAccounts(ctx, 1); SeedSite(ctx, "BK5", 1); var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" bk5 ", 1), OrgWide(), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Should().ContainSingle(); } [Fact] public async Task Create_SameSiteCodeForAnotherClient_IsAllowed() { using var ctx = NewContext(); SeedAccounts(ctx, 1, 2); SeedSite(ctx, "BK5", 1); await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 2), OrgWide(), CancellationToken.None); ctx.Locations.Where(l => l.Name == "BK5").Select(l => l.AccountId).Should().BeEquivalentTo(new int?[] { 1, 2 }); } [Fact] public async Task Create_SiteCodeOfADeletedSite_CanBeReused() { using var ctx = NewContext(); SeedAccounts(ctx, 1); SeedSite(ctx, "BK5", 1, deleted: true); await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), CancellationToken.None); ctx.Locations.Count(l => l.Name == "BK5" && l.IsDeleted != true).Should().Be(1); } [Fact] public async Task Create_BlankSiteCode_IsAValidationError() { using var ctx = NewContext(); SeedAccounts(ctx, 1); var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" ", 1), OrgWide(), CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code is required."); } [Fact] public async Task Create_StoresTrimmedCodeNotesAndSitePhoneIndependentOfContacts() { using var ctx = NewContext(); SeedAccounts(ctx, 1); var request = CreateRequest(" DFW8 ", 1); request.Phone = "555-9000"; request.Notes = " Gate code 4411 "; await NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None); var site = ctx.Locations.Single(); site.Name.Should().Be("DFW8"); site.PhoneNumber.Should().Be("555-9000"); site.Notes.Should().Be("Gate code 4411"); } [Fact] public async Task Update_ChangingAnExistingSiteCode_IsRejected() { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", null); var act = () => NewService(ctx).UpdateLocationFromRequestAsync( site.Id, new LocationUpdateRequestDTO { Name = "BK6" }, OrgWide(), CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code cannot be changed."); ctx.Locations.AsNoTracking().Single().Name.Should().Be("BK5"); } [Fact] public async Task Update_KeepsCodeAndNotesWhenTheRequestOmitsThem() { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", null); site.Notes = "Dock 3"; site.Status = "Active"; ctx.SaveChanges(); await NewService(ctx).UpdateLocationFromRequestAsync( site.Id, new LocationUpdateRequestDTO { Name = "bk5", City = "Memphis" }, OrgWide(), CancellationToken.None); var saved = ctx.Locations.AsNoTracking().Single(); saved.Name.Should().Be("BK5"); saved.City.Should().Be("Memphis"); saved.Notes.Should().Be("Dock 3"); saved.Status.Should().Be("Active"); } [Fact] public async Task Update_BlankLegacyCode_CanBeFilledOnceButMustBeUniqueInTheClient() { using var ctx = NewContext(); SeedAccounts(ctx, 1); SeedSite(ctx, "BK5", 1); var legacy = SeedSite(ctx, "", 1); var duplicate = () => NewService(ctx).UpdateLocationFromRequestAsync( legacy.Id, new LocationUpdateRequestDTO { Name = "bk5" }, OrgWide(), CancellationToken.None); await duplicate.Should().ThrowAsync(); await NewService(ctx).UpdateLocationFromRequestAsync( legacy.Id, new LocationUpdateRequestDTO { Name = "MEM1" }, OrgWide(), CancellationToken.None); ctx.Locations.AsNoTracking().Single(l => l.Id == legacy.Id).Name.Should().Be("MEM1"); } [Theory] [InlineData("Admin")] [InlineData("Scheduler")] public async Task Delete_AdminOrScheduler_TombstonesTheSiteAndLeavesWorkOrdersAsTheyWere(string role) { using var ctx = NewContext(); SeedAccounts(ctx, 1); var site = SeedSite(ctx, "BK5", 1); ctx.Contacts.Add(new Contacts { LocationId = site.Id, FirstName = "Main", PhoneNumber = "555-0100" }); ctx.workOrders.Add(Wo(10, site.Id)); ctx.SaveChanges(); var deleted = await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None); deleted.Should().BeTrue(); var tombstone = ctx.Locations.AsNoTracking().Single(); tombstone.IsDeleted.Should().BeTrue(); tombstone.DeleterUserId.Should().Be("actor-1"); ctx.workOrders.AsNoTracking().Single().LocationId.Should().Be(site.Id, "work orders keep their site reference"); ctx.Contacts.AsNoTracking().Single().IsDeleted.Should().NotBe(true, "contacts still back open work orders"); var data = new LocationDataService(ctx); (await data.GetDetailByIdAsync(site.Id, CancellationToken.None)).Should().BeNull(); (await data.GetSiteOptionsAsync(null, CancellationToken.None)).Should().BeEmpty(); (await data.GetListPagedAsync(1, 10, null, null, CancellationToken.None)).TotalCount.Should().Be(0); (await data.GetAccountScopeAsync(site.Id, CancellationToken.None)).Exists.Should().BeFalse("a deleted site cannot be assigned to new work orders"); (await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None)).Should().BeFalse(); } [Theory] [InlineData("Dispatcher")] [InlineData(null)] public async Task Delete_WithoutDeleteSitesPermission_IsForbiddenAndKeepsTheSite(string? role) { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", null); var act = () => NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role ?? "Dispatcher"), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true); } [Fact] public async Task Delete_SiteOfAnotherClient_IsRejectedForAnAccountScopedCaller() { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", 2); var act = () => NewService(ctx).DeleteLocationByIdAsync(site.Id, AccountUser(1), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true); } [Fact] public async Task OpenWorkOrders_ExcludeTerminalDeletedAndOtherSitesWork() { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", 1); var other = SeedSite(ctx, "MEM1", 1); ctx.workOrders.AddRange( Wo(1, site.Id), Wo(2, site.Id, LifecycleStatus.Scheduled), Wo(3, site.Id, LifecycleStatus.Completed), Wo(4, site.Id, LifecycleStatus.Canceled), Wo(5, site.Id, deleted: true), Wo(6, site.Id, status: null, legacyStatus: "Completed"), Wo(7, site.Id, status: null, legacyStatus: "Open"), Wo(8, null, siteCode: "bk5"), Wo(9, null, siteCode: "BK5", accountId: 2), Wo(10, other.Id)); ctx.SaveChanges(); var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None); result!.WorkOrderIds.Should().Equal(1, 2, 7, 8); result.Count.Should().Be(4); } [Fact] public async Task OpenWorkOrders_AccountScopedCaller_SeesOnlyTheirClientsWork() { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", 1); ctx.workOrders.AddRange(Wo(1, site.Id, accountId: 1), Wo(2, site.Id, accountId: 2)); ctx.SaveChanges(); var own = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(1), CancellationToken.None); own!.WorkOrderIds.Should().Equal(1); var foreign = () => NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(2), CancellationToken.None); await foreign.Should().ThrowAsync(); } [Fact] public async Task OpenWorkOrders_ReturnFullCountButCapIds() { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", 1); ctx.workOrders.AddRange(Enumerable.Range(1, LocationService.MaxOpenWorkOrderIds + 5).Select(id => Wo(id, site.Id))); ctx.SaveChanges(); var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None); result!.Count.Should().Be(LocationService.MaxOpenWorkOrderIds + 5); result.WorkOrderIds.Should().HaveCount(LocationService.MaxOpenWorkOrderIds); } [Fact] public async Task OpenWorkOrders_MissingOrDeletedSite_ReturnsNull() { using var ctx = NewContext(); var deleted = SeedSite(ctx, "BK5", 1, deleted: true); (await NewService(ctx).GetOpenWorkOrdersAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeNull(); (await NewService(ctx).GetOpenWorkOrdersAsync(999, OrgWide(), CancellationToken.None)).Should().BeNull(); } [Fact] public async Task UpdateSiteContactInfo_SavesContactsAndNotesToTheSiteRecord() { using var ctx = NewContext(); var site = SeedSite(ctx, "BK5", null); var main = new Contacts { LocationId = site.Id, FirstName = "Old Main", PhoneNumber = "555-0100", SiteContactOrder = 0 }; ctx.Contacts.Add(main); ctx.SaveChanges(); await NewService(ctx).UpdateSiteContactInfoAsync(site.Id, new SiteContactInfoRequestDTO { Contacts = new List { new() { Id = main.Id, Name = "New Main", Phone = "555-0199" }, new() { Name = "Night Shift", Phone = "555-0200" } }, Notes = " Call ahead " }, OrgWide("Dispatcher"), CancellationToken.None); var saved = ctx.Locations.AsNoTracking().Include(l => l.Contacts).Single(); saved.Notes.Should().Be("Call ahead"); saved.Contacts!.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder) .Select(c => (c.Id == main.Id, c.FirstName, c.PhoneNumber)) .Should().Equal((true, "New Main", "555-0199"), (false, "Night Shift", "555-0200")); } [Fact] public async Task UpdateSiteContactInfo_RejectsOutOfScopeDeletedAndContactlessRequests() { using var ctx = NewContext(); var foreign = SeedSite(ctx, "BK5", 2); var deleted = SeedSite(ctx, "MEM1", 1, deleted: true); var request = new SiteContactInfoRequestDTO { Contacts = new List { new() { Name = "A", Phone = "1" } } }; await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None))) .Should().ThrowAsync(); await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None))) .Should().ThrowAsync(); await ((Func)(() => NewService(ctx).UpdateSiteContactInfoAsync( foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None))) .Should().ThrowAsync(); } [Fact] public async Task NewSiteOperations_ForwardTheCallersCancellationToken() { using var cts = new CancellationTokenSource(); var token = cts.Token; var site = new Locations { Id = 5, Name = "BK5", AccountId = 1, Contacts = new List() }; var data = new Mock(); data.Setup(d => d.GetByIdForUpdateAsync(5, token)).ReturnsAsync(site); data.Setup(d => d.GetDetailByIdAsync(5, token)).ReturnsAsync(site); data.Setup(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token)) .ReturnsAsync((1, new[] { 7 })); var service = NewService(data.Object, Mock.Of()); await service.GetOpenWorkOrdersAsync(5, OrgWide(), token); await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO { Contacts = new List { new() { Name = "A", Phone = "1" } } }, OrgWide(), token); await service.DeleteLocationByIdAsync(5, OrgWide(), token); data.Verify(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token), Times.Once); data.Verify(d => d.UpdateAsync(site, token), Times.Exactly(2)); } [Fact] public async Task Create_DuplicateCheck_ForwardsTheCallersCancellationToken() { using var cts = new CancellationTokenSource(); var token = cts.Token; var data = new Mock(); data.Setup(d => d.SiteCodeExistsAsync("BK5", 1, null, token)).ReturnsAsync(true); var accounts = new Mock(); accounts.Setup(a => a.ExistsActiveAsync(1, token)).ReturnsAsync(true); var service = NewService(data.Object, accounts.Object); var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token); await act.Should().ThrowAsync(); data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once); } [Fact] public void CompletionSnapshot_FreezesSiteNotesWhenTheWorkOrderHasNone() { var workOrder = new WorkOrder { Id = 1, Locations = new Locations { Id = 5, Name = "BK5", Notes = "Gate code 4411", Contacts = new List() } }; WorkOrderCompletionSnapshotMapper.Capture(workOrder); using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!); frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411"); } public static TheoryData, string> MissingSiteFields => new() { { "no client", r => r.AccountId = null, "Client is required." }, { "no street address", r => r.Address = " ", "Street Address is required." }, { "no city", r => r.City = null, "City is required." }, { "no state", r => r.State = "", "State is required." }, { "no contacts list", r => r.Contacts = null, "At least one contact is required." }, { "empty contacts list", r => r.Contacts = new List(), "At least one contact is required." }, { "contact without phone", r => r.Contacts = new List { new() { Name = "Main", Phone = " " } }, "Contact phone is required." } }; [Theory] [MemberData(nameof(MissingSiteFields))] public async Task Create_WithoutARequiredSiteField_IsAValidationErrorAndStoresNothing( string _, Action strip, string expectedMessage) { using var ctx = NewContext(); SeedAccounts(ctx, 1); var request = CreateRequest("BK9", 1); strip(request); var act = () => NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.ErrorMessage == expectedMessage); ctx.Locations.Should().BeEmpty(); } [Fact] public async Task Delete_RemovesTheSiteFromEveryLegacyRead() { using var ctx = NewContext(); SeedAccounts(ctx, 1); var deleted = SeedSite(ctx, "BK5", 1); var kept = SeedSite(ctx, "BK6", 1); var service = NewService(ctx); (await service.DeleteLocationByIdAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeTrue(); (await service.GetLocationByIdAsync(deleted.Id)).Should().BeNull(); (await service.GetLocationByIdWithDetailsAsync(deleted.Id)).Should().BeNull(); (await service.LocationExistsAsync(deleted.Id)).Should().BeFalse(); (await service.GetTotalLocationCountAsync()).Should().Be(1); (await service.GetAllLocationsAsync()).Select(l => l.Id).Should().Equal(kept.Id); (await service.GetLocationsByAccountIdAsync(1)).Select(l => l.Id).Should().Equal(kept.Id); (await service.GetLocationsPagedAsync(1, 10)).Items.Select(l => l.Id).Should().Equal(kept.Id); (await new LocationDataService(ctx).GetAddressbookPagedAsync(1, 10)).TotalCount.Should().Be(1); (await new VendorOperationsDataService(ctx, Mock.Of()).LocationExistsAsync(deleted.Id, CancellationToken.None)).Should().BeFalse(); } }