using System.Security.Claims; using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; using FluentValidation; using FluentValidation.Results; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class LocationControllerTests { private static LocationController NewController(Mock service) => new(service.Object, Mock.Of>()); private static object Prop(object source, string name) => source.GetType().GetProperty(name)!.GetValue(source)!; [Fact] public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() { var service = new Mock(); service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List { new() { Id = 1, Name = "Site" } }, TotalCount = 1, Page = 1, PageSize = 10 }); var result = await NewController(service).GetLocationList("a", 1, 10, cancellationToken: CancellationToken.None); var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; envelope.PageNumber.Should().Be(1); envelope.PageSize.Should().Be(10); envelope.TotalCount.Should().Be(1); envelope.TotalPages.Should().Be(1); } [Fact] public async Task GetLocationList_PassesStatesFilterToService() { var service = new Mock(); service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedResult { Items = new List(), TotalCount = 0, Page = 1, PageSize = 10 }); var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None); result.Should().BeOfType(); service.VerifyAll(); } [Fact] public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape() { var service = new Mock(); service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException(new[] { new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.") })); var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Validation Error"); response.Message.Should().Contain("ZZ"); } [Fact] public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity() { var service = new Mock(); service.Setup(s => s.GetLocationDetailAsync(5, It.IsAny())) .ReturnsAsync((LocationDTO?)null); var result = await NewController(service).GetLocationById(5, CancellationToken.None); var notFound = result.Should().BeOfType().Subject; var response = notFound.Value.Should().BeOfType().Subject; response.Message.Should().Be("Location not found"); } [Fact] public async Task GetLocationById_WhenFound_MapsServiceDtoToAliasedShape() { var service = new Mock(); service.Setup(s => s.GetLocationDetailAsync(7, It.IsAny())) .ReturnsAsync(new LocationDTO { Id = 7, Name = "N", Address1 = "A", Zip = "Z", PhoneNumber = "P", Email = "E", Status = "Active" }); var result = await NewController(service).GetLocationById(7, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var body = ok.Value!; Prop(body, "Id").Should().Be(7); Prop(body, "Name").Should().Be("N"); Prop(body, "Address").Should().Be("A"); Prop(body, "ZipCode").Should().Be("Z"); Prop(body, "Phone").Should().Be("P"); Prop(body, "ContactEmail").Should().Be("E"); Prop(body, "Status").Should().Be("Active"); } [Fact] public async Task AddLocation_WhenServiceSucceeds_ReturnsCreatedResponse() { var service = new Mock(); var result = await NewController(service).AddLocation(new Location_DTO { Name = "X" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("200"); response.Message.Should().Be("Location Created Successfully"); service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task AddLocation_MapsAccountIdFromBodyString() { var service = new Mock(); LocationCreateRequestDTO? captured = null; service .Setup(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((dto, _, _) => captured = dto) .Returns(Task.CompletedTask); var result = await NewController(service).AddLocation( new Location_DTO { Name = "X", AccountId = "1" }, CancellationToken.None); result.Should().BeOfType(); captured.Should().NotBeNull(); captured!.AccountId.Should().Be(1); } [Fact] public async Task AddLocation_InvalidAccountIdString_ReturnsValidationError() { var service = new Mock(); var result = await NewController(service).AddLocation( new Location_DTO { Name = "X", AccountId = "abc" }, CancellationToken.None); var bad = result.Should().BeOfType().Subject; bad.Value.Should().BeOfType().Subject.Message.Should().Contain("accountId must be a valid integer."); service.Verify( s => s.CreateLocationFromRequestAsync( It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task EditLocation_InvalidAccountIdString_ReturnsValidationError() { var service = new Mock(); var result = await NewController(service).EditLocation( 1, new EditLocation_DTO { Name = "X", AccountId = "abc" }, CancellationToken.None); var bad = result.Should().BeOfType().Subject; bad.Value.Should().BeOfType().Subject.Message.Should().Contain("accountId must be a valid integer."); service.Verify( s => s.UpdateLocationFromRequestAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task EditLocation_WhenMissing_ReturnsNotFound() { var service = new Mock(); service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException()); var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None); var notFound = result.Should().BeOfType().Subject; notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Location not found"); } [Fact] public async Task GetLocationList_ForwardsSortParamsToService() { var service = new Mock(); service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc")) .ReturnsAsync(new PagedResult { Items = new List(), TotalCount = 0, Page = 1, PageSize = 10 }); await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None); service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), "city", "desc"), Times.Once); } [Fact] public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape() { var service = new Mock(); service.Setup(s => s.GetLocationListPagedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new ValidationException(new[] { new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.") })); var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Validation Error"); response.Message.Should().Contain("sortBy"); } [Fact] public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields() { var service = new Mock(); service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny(), null, null)) .ReturnsAsync(new PagedResult { Items = new List { new() { Id = 1, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", Zip = "63101", PhoneNumber = "555-0100", Email = "poc@example.com", AccountId = 3, AccountName = "Acme Co", Contacts = new List { new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" }, new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" } } } }, TotalCount = 1, Page = 1, PageSize = 10 }); var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None); var ok = result.Should().BeOfType().Subject; using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value); var row = json.RootElement.GetProperty("Data").EnumerateArray().Single(); row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field"); row.GetProperty("Address").GetString().Should().Be("9 River Rd"); row.GetProperty("City").GetString().Should().Be("St. Louis"); row.GetProperty("State").GetString().Should().Be("MO"); row.GetProperty("ZipCode").GetString().Should().Be("63101"); row.GetProperty("Phone").GetString().Should().Be("555-0100"); row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com"); row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field"); row.GetProperty("AccountId").GetInt32().Should().Be(3); row.GetProperty("ClientName").GetString().Should().Be("Acme Co"); row.GetProperty("AccountName").GetString().Should().Be("Acme Co"); var contacts = row.GetProperty("Contacts"); contacts.GetArrayLength().Should().Be(2); contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane"); contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford"); } [Fact] public async Task AddLocation_DuplicateSiteCode_Returns409WithStableCode() { var service = new Mock(); service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException()); var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None); var conflict = result.Should().BeOfType().Subject; Prop(conflict.Value!, "Code").Should().Be("DuplicateSiteCode"); Prop(conflict.Value!, "Message").Should().Be("This site code already exists."); } [Fact] public async Task EditLocation_DuplicateSiteCode_Returns409() { var service = new Mock(); service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException()); var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None); result.Should().BeOfType(); } [Fact] public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage() { var service = new Mock(); service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny(), It.IsAny())) .ThrowsAsync(new SeaHaven.Services.Exceptions.SiteForbiddenException( SeaHaven.Services.Exceptions.SiteForbiddenException.DeleteDeniedMessage)); var result = await NewController(service).DeleteLocation(4, CancellationToken.None); var forbidden = result.Should().BeOfType().Subject; forbidden.StatusCode.Should().Be(403); forbidden.Value.Should().BeOfType().Which.Message.Should().Be("You are not allowed to delete sites."); } [Fact] public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404() { var service = new Mock(); service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny(), It.IsAny())) .ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } }); var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None)) .Should().BeOfType().Subject; ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } }); (await NewController(service).GetOpenWorkOrders(5, CancellationToken.None)) .Should().BeOfType(); } [Fact] public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService() { var service = new Mock(); SiteContactInfoRequestDTO? seen = null; service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, request, _, _) => seen = request) .Returns(Task.CompletedTask); var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO { Contacts = new List { new() { Id = 7, Name = "Main", Phone = "555-0100" } }, Notes = "Gate 4" }, CancellationToken.None); result.Should().BeOfType(); seen!.Notes.Should().Be("Gate 4"); seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" }); } }