fix(locations): reject unparseable accountId and forward cancellation

Blank accountId stays optional; nonblank parse failures return 400. Account lookup uses ExistsActiveAsync with the request token.
This commit is contained in:
Arthur Bassi 2026-08-26 14:18:38 -03:00
parent 2718fdd294
commit 4e4bb0ca90
5 changed files with 97 additions and 4 deletions

View file

@ -110,6 +110,46 @@ public class LocationControllerTests
captured!.AccountId.Should().Be(1);
}
[Fact]
public async Task AddLocation_InvalidAccountIdString_ReturnsValidationError()
{
var service = new Mock<ILocationService>();
var result = await NewController(service).AddLocation(
new Location_DTO { Name = "X", AccountId = "abc" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Contain("accountId must be a valid integer.");
service.Verify(
s => s.CreateLocationFromRequestAsync(
It.IsAny<LocationCreateRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task EditLocation_InvalidAccountIdString_ReturnsValidationError()
{
var service = new Mock<ILocationService>();
var result = await NewController(service).EditLocation(
1,
new EditLocation_DTO { Name = "X", AccountId = "abc" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Contain("accountId must be a valid integer.");
service.Verify(
s => s.UpdateLocationFromRequestAsync(
It.IsAny<int>(),
It.IsAny<LocationUpdateRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task EditLocation_WhenMissing_ReturnsNotFound()
{

View file

@ -2,7 +2,9 @@ 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;
@ -297,6 +299,34 @@ public class LocationServiceTests
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task CreateLocationFromRequestAsync_ForwardsCancellationToAccountLookup()
{
using var ctx = NewContext();
var accounts = new Mock<IAccountDataService>();
CancellationToken seen = default;
accounts
.Setup(a => a.ExistsActiveAsync(9, It.IsAny<CancellationToken>()))
.Callback<int, CancellationToken>((_, token) => seen = token)
.ReturnsAsync(true);
var service = new LocationService(
new LocationDataService(ctx),
accounts.Object,
new CreateLocationValidation(),
new UpdateLocationValidation());
using var cts = new CancellationTokenSource();
await service.CreateLocationFromRequestAsync(
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()
{

View file

@ -17,8 +17,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Status { get; set; }
public string? AccountId { get; set; }
public int? GetAccountId() =>
int.TryParse(AccountId, out var id) ? id : null;
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);
// ✅ Map API DTO to Service DTO
public UpdateLocationDTO ToServiceUpdateDTO()

View file

@ -0,0 +1,25 @@
using System.Globalization;
using FluentValidation;
using FluentValidation.Results;
namespace Api.SeaHavenIndustries.DTOs
{
internal static class LocationAccountIdMapping
{
public static int? ParseOptional(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return null;
if (!int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
{
throw new ValidationException(new[]
{
new ValidationFailure("AccountId", "accountId must be a valid integer.")
});
}
return id;
}
}
}

View file

@ -17,8 +17,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Status { get; set; }
public string? AccountId { get; set; }
public int? GetAccountId() =>
int.TryParse(AccountId, out var id) ? id : null;
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);
// ✅ Map API DTO to Service DTO
public CreateLocationDTO ToServiceCreateDTO()