using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.EntityFrameworkCore; 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 LocationServiceTests { /// 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(databaseName: 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 void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer") { ctx.Accounts.Add(new Accounts { Id = id, Name = name, IsDeleted = false }); ctx.SaveChanges(); } private static Locations SeedLocation(ApplicationDbContext ctx, string name, string? city = null, string? status = "Active") { var loc = new Locations { Name = name, City = city, Status = status, CreatedDate = DateTime.Now }; ctx.Locations.Add(loc); ctx.SaveChanges(); return loc; } 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 ClaimsPrincipal AccountUser(int accountId, string role = "Dispatcher") { var claims = new List { new(ClaimTypes.NameIdentifier, "actor-1"), new(ClaimTypes.Role, role), new(SeaHavenClaimTypes.AccountId, accountId.ToString()) }; return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } private static ClaimsPrincipal MissingScope() { var claims = new List { new(ClaimTypes.NameIdentifier, "actor-1"), new(ClaimTypes.Role, "Dispatcher") }; return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } [Fact] public async Task CreateLocationFromRequestAsync_PersistsMappedFields() { using var ctx = NewContext(); SeedAccount(ctx, 9); var service = NewService(ctx); await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Title = "Main WH", Address = "1 Depot Rd", City = "Austin", State = "TX", ZipCode = "73301", Phone = "555-1000", ContactEmail = "wh@example.com", Status = "Active", AccountId = 9 }), OrgWideAdmin(), CancellationToken.None); var entity = ctx.Locations.Single(); entity.Name.Should().Be("Warehouse"); entity.AccountId.Should().Be(9); entity.Title.Should().Be("Main WH"); entity.Address1.Should().Be("1 Depot Rd"); entity.City.Should().Be("Austin"); entity.State.Should().Be("TX"); entity.Zip.Should().Be("73301"); entity.PhoneNumber.Should().Be("555-1000"); entity.Email.Should().Be("wh@example.com"); entity.Status.Should().Be("Active"); } [Fact] public async Task GetLocationListPagedAsync_FiltersBySearchAndMapsShape() { using var ctx = NewContext(); SeedLocation(ctx, "Alpha Site", "Austin"); SeedLocation(ctx, "Beta Site", "Dallas"); SeedLocation(ctx, "Gamma Yard", "Austin"); var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", cancellationToken: CancellationToken.None); page.Items.Should().HaveCount(2); page.TotalCount.Should().Be(2); page.Items.All(l => l.City == "Austin").Should().BeTrue(); page.Items.Select(l => l.Name).Should().NotBeNull(); } [Fact] public async Task GetLocationListPagedAsync_NormalizesStates_CaseWhitespaceAndDuplicates() { var data = new Mock(); IReadOnlyCollection? captured = default; data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states) .ReturnsAsync((new List(), 0)); await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None); captured.Should().NotBeNull(); captured.Should().BeEquivalentTo(new[] { "TX", "TEXAS", "MO", "MISSOURI" }); captured.Should().HaveCount(4); } [Fact] public async Task GetLocationListPagedAsync_EmptyOrWhitespaceStates_PassesNoStateFilter() { foreach (var states in new[] { null, "", " ", " , , " }) { var data = new Mock(); IReadOnlyCollection? captured = default; data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter) .ReturnsAsync((new List(), 0)); var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None); captured.Should().BeNull($"states input '{states}' must mean no filter"); page.Should().NotBeNull(); } } [Fact] public async Task GetLocationListPagedAsync_InvalidStates_ThrowsValidationAndNeverQueries() { var data = new Mock(); var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.PropertyName == "states" && e.ErrorMessage.Contains("ZZ") && e.ErrorMessage.Contains("QQ") && !e.ErrorMessage.Contains("TX")); data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes() { 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(), 0)); var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All); var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, allStates, CancellationToken.None); await act.Should().NotThrowAsync(); } [Fact] public async Task GetLocationListPagedAsync_AppliesNormalizedStatesThroughDataService() { using var ctx = NewContext(); SeedLocation(ctx, "Alpha Site", "Austin").State = "TX"; SeedLocation(ctx, "Beta Site", "Dallas").State = "TX"; SeedLocation(ctx, "Gamma Yard", "Kansas City").State = "MO"; SeedLocation(ctx, "Delta Hub", "Indianapolis").State = "IN"; await ctx.SaveChangesAsync(); var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, " mo , tx ", CancellationToken.None); page.TotalCount.Should().Be(3); page.Items.Select(l => l.Name).Should().BeEquivalentTo("Alpha Site", "Beta Site", "Gamma Yard"); } private static LocationService NewServiceWithSpy(ILocationDataService dataService) => new( dataService, Mock.Of(), new CreateLocationValidation(), new UpdateLocationValidation(), Mock.Of(), new SeaHaven.Services.Implementation.TeamPermissionPolicy()); [Fact] public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Depot", "Houston"); var service = NewService(ctx); var found = await service.GetLocationDetailAsync(existing.Id, CancellationToken.None); var missing = await service.GetLocationDetailAsync(existing.Id + 999, CancellationToken.None); missing.Should().BeNull(); found.Should().NotBeNull(); found!.Name.Should().Be("Depot"); found.City.Should().Be("Houston"); } [Fact] public async Task UpdateLocationFromRequestAsync_UpdatesFieldsAndThrowsWhenMissing() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Old", "Round Rock"); await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO { Name = "Old", Address = "9 New St", City = "Plano", Status = "Inactive" }, OrgWideAdmin(), CancellationToken.None); var row = ctx.Locations.Single(); row.Name.Should().Be("Old", "the site code is immutable"); row.Address1.Should().Be("9 New St"); row.City.Should().Be("Plano"); row.Status.Should().Be("Inactive"); var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, OrgWideAdmin(), CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task UpdateLocationFromRequestAsync_PreservesAccountIdWhenOmitted() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Old", "Round Rock"); existing.AccountId = 4; await ctx.SaveChangesAsync(); await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO { Name = "Old", City = "Plano" }, OrgWideAdmin(), CancellationToken.None); ctx.Locations.Single().AccountId.Should().Be(4); } [Fact] public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotUpdateForeignLocationWhenAccountIdOmitted() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Foreign", "Dallas"); existing.AccountId = 99; await ctx.SaveChangesAsync(); var act = () => NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, new LocationUpdateRequestDTO { Name = "Hijacked", City = "Austin" }, AccountUser(4), CancellationToken.None); await act.Should().ThrowAsync(); var row = ctx.Locations.Single(); row.Name.Should().Be("Foreign"); row.City.Should().Be("Dallas"); row.AccountId.Should().Be(99); } [Fact] public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CanUpdateOwnLocationWhenAccountIdOmitted() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Owned", "Dallas"); existing.AccountId = 4; await ctx.SaveChangesAsync(); await NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" }, AccountUser(4), CancellationToken.None); var row = ctx.Locations.Single(); row.Name.Should().Be("Owned"); row.City.Should().Be("Austin"); row.AccountId.Should().Be(4); } [Fact] public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotUpdateOrphanWhenAccountIdOmitted() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Orphan", "Dallas"); var act = () => NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, new LocationUpdateRequestDTO { Name = "Claimed", City = "Austin" }, AccountUser(4), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Single().Name.Should().Be("Orphan"); } [Fact] public async Task UpdateLocationFromRequestAsync_MissingScope_CannotUpdate() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Owned", "Dallas"); existing.AccountId = 4; await ctx.SaveChangesAsync(); var act = () => NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, new LocationUpdateRequestDTO { Name = "Renamed" }, MissingScope(), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Single().Name.Should().Be("Owned"); } [Fact] public async Task UpdateLocationFromRequestAsync_AppliesClientAccountIdWhenProvided() { using var ctx = NewContext(); SeedAccount(ctx, 4); SeedAccount(ctx, 99); var existing = SeedLocation(ctx, "Owned", "Austin"); existing.AccountId = 4; await ctx.SaveChangesAsync(); await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO { Name = "Owned", AccountId = 99 }, OrgWideAdmin(), CancellationToken.None); ctx.Locations.Single().AccountId.Should().Be(99); } [Fact] public async Task UpdateLocationFromRequestAsync_UnknownAccount_ThrowsValidationException() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Owned", "Austin"); var act = () => NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, new LocationUpdateRequestDTO { Name = "Owned", AccountId = 404 }, OrgWideAdmin(), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Single().AccountId.Should().BeNull(); } [Fact] public async Task CreateLocationFromRequestAsync_UnknownAccount_ThrowsValidationException() { using var ctx = NewContext(); var act = () => NewService(ctx).CreateLocationFromRequestAsync( WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }), OrgWideAdmin(), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Should().BeEmpty(); } [Fact] public async Task CreateLocationFromRequestAsync_SoftDeletedAccount_ThrowsValidationException() { using var ctx = NewContext(); ctx.Accounts.Add(new Accounts { Id = 9, Name = "Gone", IsDeleted = true }); ctx.SaveChanges(); var act = () => NewService(ctx).CreateLocationFromRequestAsync( WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }), OrgWideAdmin(), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Should().BeEmpty(); } [Fact] public async Task CreateLocationFromRequestAsync_AccountScopedCaller_CannotAssignOtherAccount() { using var ctx = NewContext(); SeedAccount(ctx, 4); SeedAccount(ctx, 99); var act = () => NewService(ctx).CreateLocationFromRequestAsync( WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }), AccountUser(4), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Should().BeEmpty(); } [Fact] public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotStealOtherAccountLocation() { using var ctx = NewContext(); SeedAccount(ctx, 4); SeedAccount(ctx, 99); var existing = SeedLocation(ctx, "Owned", "Austin"); existing.AccountId = 4; await ctx.SaveChangesAsync(); var act = () => NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, new LocationUpdateRequestDTO { Name = "Owned", AccountId = 99 }, AccountUser(99), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Single().AccountId.Should().Be(4); } [Fact] public async Task CreateLocationFromRequestAsync_MissingScope_CannotAssignAccount() { using var ctx = NewContext(); SeedAccount(ctx, 9); var act = () => NewService(ctx).CreateLocationFromRequestAsync( WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }), MissingScope(), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Should().BeEmpty(); } [Fact] public async Task CreateLocationFromRequestAsync_ForwardsCancellationToAccountLookup() { using var ctx = NewContext(); var accounts = new Mock(); CancellationToken seen = default; accounts .Setup(a => a.ExistsActiveAsync(9, It.IsAny())) .Callback((_, token) => seen = token) .ReturnsAsync(true); var service = new LocationService( new LocationDataService(ctx), accounts.Object, new CreateLocationValidation(), new UpdateLocationValidation(), Mock.Of(), new SeaHaven.Services.Implementation.TeamPermissionPolicy()); using var cts = new CancellationTokenSource(); await service.CreateLocationFromRequestAsync( WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }), OrgWideAdmin(), cts.Token); seen.Should().Be(cts.Token); accounts.Verify(a => a.ExistsActiveAsync(9, cts.Token), Times.Once); } [Fact] public async Task CreateLocationAsync_IgnoresClientAccountId() { using var ctx = NewContext(); var created = await NewService(ctx).CreateLocationAsync(new CreateLocationDTO { LocationName = "Site", AccountId = 9 }, "42"); created.AccountId.Should().BeNull(); ctx.Locations.Single().AccountId.Should().BeNull(); } [Fact] public async Task UpdateLocationAsync_IgnoresClientAccountIdRelabel() { using var ctx = NewContext(); var existing = SeedLocation(ctx, "Owned", "Austin"); existing.AccountId = 4; await ctx.SaveChangesAsync(); await NewService(ctx).UpdateLocationAsync(existing.Id, new UpdateLocationDTO { LocationName = "Owned", AccountId = 99 }, "42"); ctx.Locations.Single().AccountId.Should().Be(4); } [Fact] public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService() { var data = new Mock(); string? capturedSort = "sentinel"; string? capturedDirection = "sentinel"; data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) => { capturedSort = sortBy; capturedDirection = sortDirection; }) .ReturnsAsync((new List(), 0)); await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC "); capturedSort.Should().Be("poc"); capturedDirection.Should().Be("desc"); } [Theory] [InlineData(null, null)] [InlineData("", " ")] public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults( string? sortBy, string? sortDirection) { var data = new Mock(); string? capturedSort = "sentinel"; string? capturedDirection = "sentinel"; data.Setup(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) => { capturedSort = s; capturedDirection = d; }) .ReturnsAsync((new List(), 0)); await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection); capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering"); capturedDirection.Should().Be("asc"); } [Theory] [InlineData("rating")] [InlineData("password; drop table locations")] public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy) { var data = new Mock(); var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.PropertyName == "sortBy" && e.ErrorMessage.Contains("code") && e.ErrorMessage.Contains("poc")); data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData("ascending")] [InlineData("DESC; drop table locations")] public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection) { var data = new Mock(); var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.PropertyName == "sortDirection" && e.ErrorMessage.Contains("asc") && e.ErrorMessage.Contains("desc")); data.Verify(d => d.GetListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows() { using var ctx = NewContext(); SeedAccount(ctx, 9, "Acme Co"); SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9; await ctx.SaveChangesAsync(); var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None); var row = page.Items.Should().ContainSingle().Subject; row.AccountId.Should().Be(9); row.AccountName.Should().Be("Acme Co"); } }