2026-08-26 14:16:59 -03:00
|
|
|
using System.Security.Claims;
|
2026-07-24 17:35:34 -03:00
|
|
|
using Data.SeaHavenIndustries;
|
|
|
|
|
using FluentAssertions;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
2026-08-26 14:18:38 -03:00
|
|
|
using Moq;
|
2026-07-24 17:35:34 -03:00
|
|
|
using SeaHaven.DataServices.Implementation;
|
2026-08-26 14:18:38 -03:00
|
|
|
using SeaHaven.DataServices.Interfaces;
|
2026-07-24 17:35:34 -03:00
|
|
|
using SeaHaven.Services.DTOs;
|
2026-08-26 14:16:59 -03:00
|
|
|
using SeaHaven.Services.Helpers;
|
2026-07-24 17:35:34 -03:00
|
|
|
using SeaHaven.Services.Implementation;
|
|
|
|
|
using SeaHaven.Services.Validation;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
|
|
|
|
|
|
public class LocationServiceTests
|
|
|
|
|
{
|
|
|
|
|
private static ApplicationDbContext NewContext()
|
|
|
|
|
{
|
|
|
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
|
|
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
|
|
|
.Options;
|
|
|
|
|
return new ApplicationDbContext(options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static LocationService NewService(ApplicationDbContext ctx) =>
|
2026-08-26 14:03:12 -03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 14:16:59 -03:00
|
|
|
private static ClaimsPrincipal OrgWideAdmin()
|
|
|
|
|
{
|
|
|
|
|
var claims = new List<Claim>
|
|
|
|
|
{
|
|
|
|
|
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<Claim>
|
|
|
|
|
{
|
|
|
|
|
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<Claim>
|
|
|
|
|
{
|
|
|
|
|
new(ClaimTypes.NameIdentifier, "actor-1"),
|
|
|
|
|
new(ClaimTypes.Role, "Dispatcher")
|
|
|
|
|
};
|
|
|
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
|
|
|
|
|
{
|
|
|
|
|
using var ctx = NewContext();
|
2026-08-26 14:03:12 -03:00
|
|
|
SeedAccount(ctx, 9);
|
2026-07-24 17:35:34 -03:00
|
|
|
var service = NewService(ctx);
|
|
|
|
|
|
|
|
|
|
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
|
|
|
|
{
|
|
|
|
|
Name = "Warehouse",
|
|
|
|
|
Title = "Main WH",
|
|
|
|
|
Address = "1 Depot Rd",
|
|
|
|
|
City = "Austin",
|
|
|
|
|
State = "TX",
|
|
|
|
|
ZipCode = "73301",
|
|
|
|
|
Phone = "555-1000",
|
|
|
|
|
ContactEmail = "wh@example.com",
|
2026-08-26 09:12:48 -03:00
|
|
|
Status = "Active",
|
|
|
|
|
AccountId = 9
|
2026-08-26 14:16:59 -03:00
|
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
|
|
|
var entity = ctx.Locations.Single();
|
|
|
|
|
entity.Name.Should().Be("Warehouse");
|
2026-08-26 14:03:12 -03:00
|
|
|
entity.AccountId.Should().Be(9);
|
2026-07-24 17:35:34 -03:00
|
|
|
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");
|
|
|
|
|
|
2026-09-15 16:32:30 -03:00
|
|
|
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", cancellationToken: CancellationToken.None);
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 16:32:30 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task GetLocationListPagedAsync_NormalizesStates_CaseWhitespaceAndDuplicates()
|
|
|
|
|
{
|
|
|
|
|
var data = new Mock<ILocationDataService>();
|
|
|
|
|
IReadOnlyCollection<string>? captured = default;
|
2026-09-16 20:45:15 -03:00
|
|
|
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
|
|
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
|
2026-09-15 16:32:30 -03:00
|
|
|
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
|
|
|
|
|
IReadOnlyCollection<string>? captured = default;
|
2026-09-16 20:45:15 -03:00
|
|
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
|
|
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
|
2026-09-15 16:32:30 -03:00
|
|
|
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
|
|
|
|
|
|
|
|
|
|
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, "TX, zz, QQ", CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
|
|
|
.Which.Errors.Should().ContainSingle(e =>
|
|
|
|
|
e.PropertyName == "states"
|
|
|
|
|
&& e.ErrorMessage.Contains("ZZ")
|
|
|
|
|
&& e.ErrorMessage.Contains("QQ")
|
|
|
|
|
&& !e.ErrorMessage.Contains("TX"));
|
2026-09-16 20:45:15 -03:00
|
|
|
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
2026-09-15 16:32:30 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
|
|
|
|
|
{
|
|
|
|
|
var data = new Mock<ILocationDataService>();
|
2026-09-16 20:45:15 -03:00
|
|
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
2026-09-15 16:32:30 -03:00
|
|
|
.ReturnsAsync((new List<Locations>(), 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<IAccountDataService>(),
|
|
|
|
|
new CreateLocationValidation(),
|
|
|
|
|
new UpdateLocationValidation());
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
[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 = "New",
|
|
|
|
|
Address = "9 New St",
|
|
|
|
|
City = "Plano",
|
|
|
|
|
Status = "Inactive"
|
2026-08-26 14:16:59 -03:00
|
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
2026-07-24 17:35:34 -03:00
|
|
|
|
|
|
|
|
var row = ctx.Locations.Single();
|
|
|
|
|
row.Name.Should().Be("New");
|
|
|
|
|
row.Address1.Should().Be("9 New St");
|
|
|
|
|
row.City.Should().Be("Plano");
|
|
|
|
|
row.Status.Should().Be("Inactive");
|
|
|
|
|
|
2026-08-26 14:16:59 -03:00
|
|
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, OrgWideAdmin(), CancellationToken.None);
|
2026-07-24 17:35:34 -03:00
|
|
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 10:00:02 -03:00
|
|
|
[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 = "New",
|
|
|
|
|
City = "Plano"
|
2026-08-26 14:16:59 -03:00
|
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
2026-08-26 10:00:02 -03:00
|
|
|
|
|
|
|
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 14:38:34 -03:00
|
|
|
[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<UnauthorizedAccessException>();
|
|
|
|
|
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 = "Renamed", City = "Austin" },
|
|
|
|
|
AccountUser(4),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
var row = ctx.Locations.Single();
|
|
|
|
|
row.Name.Should().Be("Renamed");
|
|
|
|
|
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<UnauthorizedAccessException>();
|
|
|
|
|
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<UnauthorizedAccessException>();
|
|
|
|
|
ctx.Locations.Single().Name.Should().Be("Owned");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 10:00:02 -03:00
|
|
|
[Fact]
|
2026-08-26 14:03:12 -03:00
|
|
|
public async Task UpdateLocationFromRequestAsync_AppliesClientAccountIdWhenProvided()
|
2026-08-26 10:00:02 -03:00
|
|
|
{
|
|
|
|
|
using var ctx = NewContext();
|
2026-08-26 14:03:12 -03:00
|
|
|
SeedAccount(ctx, 4);
|
|
|
|
|
SeedAccount(ctx, 99);
|
2026-08-26 10:00:02 -03:00
|
|
|
var existing = SeedLocation(ctx, "Owned", "Austin");
|
|
|
|
|
existing.AccountId = 4;
|
|
|
|
|
await ctx.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
|
|
|
|
{
|
|
|
|
|
Name = "Owned",
|
|
|
|
|
AccountId = 99
|
2026-08-26 14:16:59 -03:00
|
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
2026-08-26 10:00:02 -03:00
|
|
|
|
2026-08-26 14:03:12 -03:00
|
|
|
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 },
|
2026-08-26 14:16:59 -03:00
|
|
|
OrgWideAdmin(),
|
2026-08-26 14:03:12 -03:00
|
|
|
CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<FluentValidation.ValidationException>();
|
|
|
|
|
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 },
|
2026-08-26 14:16:59 -03:00
|
|
|
OrgWideAdmin(),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<FluentValidation.ValidationException>();
|
|
|
|
|
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(
|
|
|
|
|
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 },
|
|
|
|
|
OrgWideAdmin(),
|
2026-08-26 14:03:12 -03:00
|
|
|
CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<FluentValidation.ValidationException>();
|
|
|
|
|
ctx.Locations.Should().BeEmpty();
|
2026-08-26 10:00:02 -03:00
|
|
|
}
|
|
|
|
|
|
2026-08-26 14:16:59 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task CreateLocationFromRequestAsync_AccountScopedCaller_CannotAssignOtherAccount()
|
|
|
|
|
{
|
|
|
|
|
using var ctx = NewContext();
|
|
|
|
|
SeedAccount(ctx, 4);
|
|
|
|
|
SeedAccount(ctx, 99);
|
|
|
|
|
|
|
|
|
|
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
|
|
|
|
new LocationCreateRequestDTO { Name = "Site", AccountId = 99 },
|
|
|
|
|
AccountUser(4),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
|
|
|
|
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<UnauthorizedAccessException>();
|
|
|
|
|
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(
|
|
|
|
|
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
|
|
|
|
|
MissingScope(),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
|
|
|
|
ctx.Locations.Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 14:18:38 -03:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 10:00:02 -03:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 17:35:34 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
|
|
|
|
{
|
|
|
|
|
using var ctx = NewContext();
|
|
|
|
|
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
|
|
|
|
|
|
|
|
|
|
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
|
|
|
|
|
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
removed.Should().BeTrue();
|
|
|
|
|
again.Should().BeFalse();
|
|
|
|
|
ctx.Locations.Should().BeEmpty();
|
|
|
|
|
}
|
2026-09-16 20:45:15 -03:00
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
|
|
|
|
|
{
|
|
|
|
|
var data = new Mock<ILocationDataService>();
|
|
|
|
|
string? capturedSort = "sentinel";
|
|
|
|
|
string? capturedDirection = "sentinel";
|
|
|
|
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
|
|
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
|
|
|
|
|
{
|
|
|
|
|
capturedSort = sortBy;
|
|
|
|
|
capturedDirection = sortDirection;
|
|
|
|
|
})
|
|
|
|
|
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
|
|
|
|
|
string? capturedSort = "sentinel";
|
|
|
|
|
string? capturedDirection = "sentinel";
|
|
|
|
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
|
|
|
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
|
|
|
|
|
{
|
|
|
|
|
capturedSort = s;
|
|
|
|
|
capturedDirection = d;
|
|
|
|
|
})
|
|
|
|
|
.ReturnsAsync((new List<Locations>(), 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<ILocationDataService>();
|
|
|
|
|
|
|
|
|
|
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
|
|
|
|
|
|
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
|
|
|
.Which.Errors.Should().ContainSingle(e =>
|
|
|
|
|
e.PropertyName == "sortBy"
|
|
|
|
|
&& e.ErrorMessage.Contains("code")
|
|
|
|
|
&& e.ErrorMessage.Contains("poc"));
|
|
|
|
|
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Theory]
|
|
|
|
|
[InlineData("ascending")]
|
|
|
|
|
[InlineData("DESC; drop table locations")]
|
|
|
|
|
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
|
|
|
|
|
{
|
|
|
|
|
var data = new Mock<ILocationDataService>();
|
|
|
|
|
|
|
|
|
|
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
|
|
|
|
|
|
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
|
|
|
.Which.Errors.Should().ContainSingle(e =>
|
|
|
|
|
e.PropertyName == "sortDirection"
|
|
|
|
|
&& e.ErrorMessage.Contains("asc")
|
|
|
|
|
&& e.ErrorMessage.Contains("desc"));
|
|
|
|
|
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), 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");
|
|
|
|
|
}
|
2026-07-24 17:35:34 -03:00
|
|
|
}
|