From 2be36d4eacffcd1298eb94384ea02dfe7285b755 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 14:03:12 -0300 Subject: [PATCH] feat(locations): persist accountId on create and update Allow location CRUD to stamp Locations.AccountId after account existence checks so board create can resolve tenant scope. --- .../LocationControllerSitesTests.cs | 1 + .../LocationControllerTests.cs | 19 +++++++ .../LocationServiceTests.cs | 49 +++++++++++++++++-- .../Controllers/LocationController.cs | 6 ++- .../Implementation/LocationService.cs | 29 ++++++++++- 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs index ebbd3d6..dc5fded 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -29,6 +29,7 @@ public class LocationControllerSitesTests var dataService = new LocationDataService(ctx); var service = new LocationService( dataService, + new AccountDataService(ctx), Mock.Of(), Mock.Of()); diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index d6dc573..89fed73 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -90,6 +90,25 @@ public class LocationControllerTests service.Verify(s => s.CreateLocationFromRequestAsync(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())) + .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 EditLocation_WhenMissing_ReturnsNotFound() { diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 844b28f..8c0a273 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -20,7 +20,17 @@ public class LocationServiceTests } private static LocationService NewService(ApplicationDbContext ctx) => - new(new LocationDataService(ctx), new CreateLocationValidation(), new UpdateLocationValidation()); + new( + new LocationDataService(ctx), + new AccountDataService(ctx), + new CreateLocationValidation(), + new UpdateLocationValidation()); + + 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") { @@ -34,6 +44,7 @@ public class LocationServiceTests public async Task CreateLocationFromRequestAsync_PersistsMappedFields() { using var ctx = NewContext(); + SeedAccount(ctx, 9); var service = NewService(ctx); await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO @@ -52,7 +63,7 @@ public class LocationServiceTests var entity = ctx.Locations.Single(); entity.Name.Should().Be("Warehouse"); - entity.AccountId.Should().BeNull(); + entity.AccountId.Should().Be(9); entity.Title.Should().Be("Main WH"); entity.Address1.Should().Be("1 Depot Rd"); entity.City.Should().Be("Austin"); @@ -137,9 +148,11 @@ public class LocationServiceTests } [Fact] - public async Task UpdateLocationFromRequestAsync_IgnoresClientAccountIdRelabel() + 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(); @@ -150,7 +163,35 @@ public class LocationServiceTests AccountId = 99 }, CancellationToken.None); - ctx.Locations.Single().AccountId.Should().Be(4); + 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 }, + 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( + new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Should().BeEmpty(); } [Fact] diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 977bdda..dd91983 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -177,7 +177,8 @@ namespace Api.SeaHavenIndustries.Controllers ZipCode = model.ZipCode, Phone = model.Phone, ContactEmail = model.ContactEmail, - Status = model.Status + Status = model.Status, + AccountId = model.GetAccountId() }; } @@ -193,7 +194,8 @@ namespace Api.SeaHavenIndustries.Controllers ZipCode = model.ZipCode, Phone = model.Phone, ContactEmail = model.ContactEmail, - Status = model.Status + Status = model.Status, + AccountId = model.GetAccountId() }; } } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 6be9d0f..a8e22cd 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using FluentValidation; +using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -14,15 +15,18 @@ namespace SeaHaven.Services.Implementation public class LocationService : ILocationService { private readonly ILocationDataService _locationDataService; + private readonly IAccountDataService _accountDataService; private readonly ICreateLocationValidation _createValidator; private readonly IUpdateLocationValidation _updateValidator; public LocationService( ILocationDataService locationDataService, + IAccountDataService accountDataService, ICreateLocationValidation createValidator, IUpdateLocationValidation updateValidator) { _locationDataService = locationDataService; + _accountDataService = accountDataService; _createValidator = createValidator; _updateValidator = updateValidator; } @@ -177,6 +181,8 @@ namespace SeaHaven.Services.Implementation public async Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken) { + await EnsureAccountExistsAsync(request.AccountId); + var location = new Locations { Name = request.Name, @@ -187,7 +193,8 @@ namespace SeaHaven.Services.Implementation Zip = request.ZipCode, PhoneNumber = request.Phone, Email = request.ContactEmail, - Status = request.Status + Status = request.Status, + AccountId = request.AccountId }; await _locationDataService.AddAsync(location, cancellationToken); @@ -209,6 +216,12 @@ namespace SeaHaven.Services.Implementation location.Email = request.ContactEmail; location.Status = request.Status; + if (request.AccountId is int accountId) + { + await EnsureAccountExistsAsync(accountId); + location.AccountId = accountId; + } + await _locationDataService.UpdateAsync(location, cancellationToken); } @@ -217,6 +230,20 @@ namespace SeaHaven.Services.Implementation return _locationDataService.DeleteByIdAsync(id, cancellationToken); } + private async Task EnsureAccountExistsAsync(int? accountId) + { + if (accountId is not int id) + return; + + if (!await _accountDataService.ExistsAsync(id)) + { + throw new ValidationException(new[] + { + new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Account was not found.") + }); + } + } + // Private helper methods private LocationDTO MapToDTO(Locations location) {