diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index dbf05cb..2a3f17c 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -15,6 +15,27 @@ 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() @@ -85,7 +106,7 @@ public class LocationServiceTests SeedAccount(ctx, 9); var service = NewService(ctx); - await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO + await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Title = "Main WH", @@ -97,7 +118,7 @@ public class LocationServiceTests ContactEmail = "wh@example.com", Status = "Active", AccountId = 9 - }, OrgWideAdmin(), CancellationToken.None); + }), OrgWideAdmin(), CancellationToken.None); var entity = ctx.Locations.Single(); entity.Name.Should().Be("Warehouse"); @@ -390,7 +411,7 @@ public class LocationServiceTests using var ctx = NewContext(); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }), OrgWideAdmin(), CancellationToken.None); @@ -406,7 +427,7 @@ public class LocationServiceTests ctx.SaveChanges(); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }), OrgWideAdmin(), CancellationToken.None); @@ -422,7 +443,7 @@ public class LocationServiceTests SeedAccount(ctx, 99); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }), AccountUser(4), CancellationToken.None); @@ -457,7 +478,7 @@ public class LocationServiceTests SeedAccount(ctx, 9); var act = () => NewService(ctx).CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }), MissingScope(), CancellationToken.None); @@ -487,7 +508,7 @@ public class LocationServiceTests using var cts = new CancellationTokenSource(); await service.CreateLocationFromRequestAsync( - new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }, + WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }), OrgWideAdmin(), cts.Token); diff --git a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs index 5e64489..b366331 100644 --- a/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs @@ -22,6 +22,27 @@ 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() @@ -53,7 +74,7 @@ public class LocationSiteContactsTests private static async Task SeedLocationWithContactsAsync(ApplicationDbContext ctx) { var service = NewService(ctx); - await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO + await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Depot", Phone = "555-9000", @@ -63,7 +84,7 @@ public class LocationSiteContactsTests new() { Name = " Alice Cooper ", Phone = " 555-0100 " }, new() { Name = "Bob Dillon", Phone = "555-0200"} } - }, OrgWideAdmin(), CancellationToken.None); + }), OrgWideAdmin(), CancellationToken.None); return ctx.Locations.Include(l => l.Contacts).Single(); } @@ -74,7 +95,7 @@ public class LocationSiteContactsTests ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false }); await ctx.SaveChangesAsync(); - await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO + await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 7, @@ -83,7 +104,7 @@ public class LocationSiteContactsTests new() { Name = " Alice Cooper ", Phone = " 555-0100 " }, new() { Name = "Bob Dillon", Phone = "555-0200" } } - }, OrgWideAdmin(), CancellationToken.None); + }), OrgWideAdmin(), CancellationToken.None); var location = ctx.Locations.Include(l => l.Contacts).Single(); location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts"); @@ -107,11 +128,11 @@ public class LocationSiteContactsTests { using var ctx = NewContext(); - var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO + var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Contacts = new List() - }, OrgWideAdmin(), CancellationToken.None); + }), OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts"); @@ -130,11 +151,11 @@ public class LocationSiteContactsTests using var ctx = NewContext(); var contacts = new List { new() { Name = name, Phone = phone } }; - var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO + var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Contacts = contacts - }, OrgWideAdmin(), CancellationToken.None); + }), OrgWideAdmin(), CancellationToken.None); if (expectedError == null) { @@ -151,14 +172,14 @@ public class LocationSiteContactsTests { using var ctx = NewContext(); - var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO + 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); + }), OrgWideAdmin(), CancellationToken.None); var thrown = (await act.Should().ThrowAsync()).Which; thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100")); @@ -173,11 +194,11 @@ public class LocationSiteContactsTests .Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" }) .ToList(); - var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO + var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", Contacts = contacts - }, OrgWideAdmin(), CancellationToken.None); + }), OrgWideAdmin(), CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20")); diff --git a/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs b/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs index 5e8c18c..c5a718f 100644 --- a/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs @@ -423,13 +423,15 @@ public class SiteRegistryServiceTests using var cts = new CancellationTokenSource(); var token = cts.Token; var data = new Mock(); - data.Setup(d => d.SiteCodeExistsAsync("BK5", null, null, token)).ReturnsAsync(true); - var service = NewService(data.Object, Mock.Of()); + 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", null), OrgWide(), token); + var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token); await act.Should().ThrowAsync(); - data.Verify(d => d.SiteCodeExistsAsync("BK5", null, null, token), Times.Once); + data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once); } [Fact] @@ -446,4 +448,56 @@ public class SiteRegistryServiceTests 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(); + } } diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index d1553a2..5215e0e 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -17,25 +17,25 @@ namespace SeaHaven.DataServices.Implementation public async Task GetByIdAsync(int id) { - return await _context.Locations.FindAsync(id); + return await _context.Locations.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true); } public async Task GetByIdWithDetailsAsync(int id) { return await _context.Locations - .FirstOrDefaultAsync(l => l.Id == id); + .FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true); } public async Task> GetAllAsync() { - return await _context.Locations.ToListAsync(); + return await _context.Locations.Where(l => l.IsDeleted != true).ToListAsync(); } public async Task> GetByAccountIdAsync(int accountId) { return await _context.Locations .AsNoTracking() - .Where(l => l.AccountId == accountId) + .Where(l => l.AccountId == accountId && l.IsDeleted != true) .ToListAsync(); } @@ -57,7 +57,7 @@ namespace SeaHaven.DataServices.Implementation int pageSize, string? search = null) { - var query = _context.Locations.AsQueryable(); + var query = _context.Locations.Where(l => l.IsDeleted != true); if (!string.IsNullOrWhiteSpace(search)) { @@ -125,12 +125,12 @@ namespace SeaHaven.DataServices.Implementation public async Task ExistsAsync(int id) { - return await _context.Locations.AnyAsync(l => l.Id == id); + return await _context.Locations.AnyAsync(l => l.Id == id && l.IsDeleted != true); } public async Task CountAsync() { - return await _context.Locations.CountAsync(); + return await _context.Locations.CountAsync(l => l.IsDeleted != true); } public async Task<(List Items, int TotalCount)> GetListPagedAsync( @@ -313,7 +313,7 @@ namespace SeaHaven.DataServices.Implementation { search ??= ""; - var query = _context.Locations.AsQueryable(); + var query = _context.Locations.Where(l => l.IsDeleted != true); if (!string.IsNullOrWhiteSpace(search)) { diff --git a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs index 4fabfba..1ae72ac 100644 --- a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs @@ -150,7 +150,7 @@ namespace SeaHaven.DataServices.Implementation } public Task LocationExistsAsync(int locationId, CancellationToken cancellationToken) - => _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken); + => _context.Locations.AnyAsync(location => location.Id == locationId && location.IsDeleted != true, cancellationToken); public async Task> GetVendorsByIdsAsync(IReadOnlyCollection vendorIds, CancellationToken cancellationToken) => await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)) diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index bc1772a..fabeb97 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -273,6 +273,7 @@ namespace SeaHaven.Services.Implementation CancellationToken cancellationToken) { await EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken); + ThrowOnMissingSiteFields(request); var siteCode = request.Name?.Trim(); if (string.IsNullOrEmpty(siteCode)) @@ -300,10 +301,7 @@ namespace SeaHaven.Services.Implementation Notes = NormalizeNotes(request.Notes) }; - if (request.Contacts is List contacts) - { - ApplySiteContactsForCreate(location, contacts, GetActorId(user)); - } + ApplySiteContactsForCreate(location, request.Contacts!, GetActorId(user)); await _locationDataService.AddAsync(location, cancellationToken); } @@ -548,6 +546,25 @@ namespace SeaHaven.Services.Implementation private static string? GetActorId(ClaimsPrincipal user) => user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + /// A new site needs a client, a full address and at least one point of contact. + private static void ThrowOnMissingSiteFields(LocationCreateRequestDTO request) + { + var failures = new List(); + if (request.AccountId == null) + failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Client is required.")); + if (string.IsNullOrWhiteSpace(request.Address)) + failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Address), "Street Address is required.")); + if (string.IsNullOrWhiteSpace(request.City)) + failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.City), "City is required.")); + if (string.IsNullOrWhiteSpace(request.State)) + failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.State), "State is required.")); + if (request.Contacts == null || request.Contacts.Count == 0) + failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Contacts), "At least one contact is required.")); + + if (failures.Count > 0) + throw new ValidationException(failures); + } + private static void ThrowOnInvalidContacts(List contacts) { var failures = SiteContactsValidation.Validate(contacts);