backend phase 4: geography, addresses & nurse service areas
Adds the province -> city -> district reference hierarchy (geo schema, seeded with 31 provinces + capital cities + Tehran's 22 districts), nurse service areas (district_id NULL = whole city, filtered-index-pair uniqueness -> 409), and encrypted, geocoded customer addresses with a single-primary invariant. Introduces the IGeocoder seam (mocked) and 409 Conflict on the result envelope. Public cascading lookups are cached behind a generation-token scheme with invalidate-on-admin-write. One EF migration (GeographyAddressesServiceAreas, applied). Contract + swagger snapshot + handoff/report/registry updated. 103 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Addresses.Commands.CreateAddress;
|
||||
using Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Geography;
|
||||
|
||||
public class CustomerAddressHandlersTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
|
||||
private readonly ICustomerAddressRepository _addresses = Substitute.For<ICustomerAddressRepository>();
|
||||
private readonly IGeoRepository _geo = Substitute.For<IGeoRepository>();
|
||||
private readonly IGeocoder _geocoder = Substitute.For<IGeocoder>();
|
||||
|
||||
public CustomerAddressHandlersTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
_unitOfWork.CustomerProfileRepository.Returns(_customers);
|
||||
_unitOfWork.CustomerAddressRepository.Returns(_addresses);
|
||||
_unitOfWork.GeoRepository.Returns(_geo);
|
||||
_geo.GetCityAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new City { NameFa = "تهران", NameEn = "Tehran" });
|
||||
_geo.IsCityActiveAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
}
|
||||
|
||||
private CreateAddressCommand Body(bool isPrimary = false) =>
|
||||
new("خانه", 101L, null, "خیابان ولیعصر، پلاک ۱۰", "1234567890", "علی", "09120000000", isPrimary);
|
||||
|
||||
[Fact]
|
||||
public async Task Create_FirstAddress_IsPrimaryAndGeocoded()
|
||||
{
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_addresses.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(false);
|
||||
_geocoder.GeocodeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new GeocodeResult(35.6892m, 51.3890m, "Tehran", 0.9));
|
||||
var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder);
|
||||
|
||||
var result = await handler.Handle(Body(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(35.6892m, result.Result.Latitude);
|
||||
await _addresses.Received(1).AddAsync(
|
||||
Arg.Is<CustomerAddress>(a => a.IsPrimary && a.Latitude == 35.6892m && a.CustomerId == 42L),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SecondAddressAsPrimary_ClearsPriorPrimary()
|
||||
{
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_addresses.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(true);
|
||||
_geocoder.GeocodeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new GeocodeResult(1m, 1m, "x", 0.9));
|
||||
var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder);
|
||||
|
||||
var result = await handler.Handle(Body(isPrimary: true), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _addresses.Received(1).ClearOtherPrimaryAsync(42L, 0L, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_LowConfidenceGeocode_SavedWithoutCoordinates()
|
||||
{
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_addresses.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(false);
|
||||
_geocoder.GeocodeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new GeocodeResult(null, null, "x", 0.2));
|
||||
var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder);
|
||||
|
||||
var result = await handler.Handle(Body(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(result.Result.Latitude);
|
||||
await _addresses.Received(1).AddAsync(
|
||||
Arg.Is<CustomerAddress>(a => a.Latitude == null && a.Longitude == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NonCustomer_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder);
|
||||
|
||||
var result = await handler.Handle(Body(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsForbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPrimary_OtherCustomersAddress_IsNotFound()
|
||||
{
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_addresses.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new SetPrimaryAddressCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new SetPrimaryAddressCommand(99L), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
await _addresses.DidNotReceive().SetPrimaryAsync(Arg.Any<long>(), Arg.Any<long>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Geography;
|
||||
|
||||
public class NurseServiceAreaHandlersTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly IGeoRepository _geo = Substitute.For<IGeoRepository>();
|
||||
private readonly INurseServiceAreaRepository _areas = Substitute.For<INurseServiceAreaRepository>();
|
||||
|
||||
public NurseServiceAreaHandlersTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_unitOfWork.GeoRepository.Returns(_geo);
|
||||
_unitOfWork.NurseServiceAreaRepository.Returns(_areas);
|
||||
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_geo.GetCityAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new City { NameFa = "تهران", NameEn = "Tehran" });
|
||||
_geo.IsCityActiveAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WholeCity_PersistsWholeCityRow()
|
||||
{
|
||||
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(false);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.IsWholeCity);
|
||||
await _areas.Received(1).AddAsync(
|
||||
Arg.Is<NurseServiceArea>(a => a.NurseId == 42L && a.CityId == 101L && a.DistrictId == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_DuplicateWholeCity_ReturnsConflictNotPersisted()
|
||||
{
|
||||
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(true);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsConflict);
|
||||
await _areas.DidNotReceive().AddAsync(Arg.Any<NurseServiceArea>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_DuplicateDistrict_ReturnsConflict()
|
||||
{
|
||||
_geo.IsDistrictInActiveCityAsync(5L, 101L, Arg.Any<CancellationToken>()).Returns(true);
|
||||
_geo.GetDistrictAsync(5L, Arg.Any<CancellationToken>())
|
||||
.Returns(new District { NameFa = "منطقه ۱", NameEn = "District 1" });
|
||||
_areas.DuplicateExistsAsync(42L, 101L, 5L, Arg.Any<CancellationToken>()).Returns(true);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 5L), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_DistrictNotInCity_Fails()
|
||||
{
|
||||
_geo.IsDistrictInActiveCityAsync(999L, 101L, Arg.Any<CancellationToken>()).Returns(false);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 999L), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _areas.DidNotReceive().AddAsync(Arg.Any<NurseServiceArea>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_NonNurse_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsForbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_OtherNursesArea_IsNotFound()
|
||||
{
|
||||
_areas.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For<IDateTimeProvider>());
|
||||
|
||||
var result = await handler.Handle(new RemoveNurseServiceAreaCommand(99L), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user