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:
hamid
2026-07-02 16:06:12 +03:30
parent 39a979b1a7
commit 82561c4cc6
113 changed files with 9817 additions and 5 deletions
@@ -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();
}
}