backend phase 3: identity profiles, patients & nurse bank accounts

Add the role-attached identity layer on top of the b2 auth spine: nurse
seller profiles (guarded is_verified, read-only aggregates), thin customer
payer profiles, first-class patients (tenancy-scoped), and nurse payout bank
accounts hardened with an iban_hash uniqueness guard and an automated استعلام
شبا IBAN-ownership inquiry.

- Four usr tables via one migration (1:1 uniques, UNIQUE(iban_hash), filtered
  UNIQUE(nurse_id) WHERE is_primary=1, guarded is_verified, encrypted PII,
  soft-delete on nurse_profiles)
- 15 CQRS slices + 4 role-scoped controllers; reads projected + paginated,
  IBAN masked (last-4); ownership-inquiry endpoints rate-limited
- New IBankAccountOwnershipVerifier seam (mock deterministic شبا match) +
  per-domain repositories on IUnitOfWork + encrypted-PII value converters
- Activate FluentValidation repo-wide (validators were never registered)
- Handler unit tests + WebApplicationFactory integration tests (76 pass);
  contract identity-profiles.md + swagger snapshot; docs, handoff & report

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
hamid
2026-07-02 12:03:15 +03:30
parent 17a82832ab
commit 39a979b1a7
89 changed files with 6060 additions and 13 deletions
@@ -0,0 +1,127 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
using Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class NurseBankAccountHandlersTests
{
private const string ValidIban = "IR062960000000100324200001";
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
private readonly IFieldEncryptor _encryptor = Substitute.For<IFieldEncryptor>();
private readonly IBankAccountOwnershipVerifier _verifier = Substitute.For<IBankAccountOwnershipVerifier>();
public NurseBankAccountHandlersTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_unitOfWork.NurseBankAccountRepository.Returns(_accounts);
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new NurseIdentityContext(42L, "0012345678"));
_encryptor.Hash(Arg.Any<string>()).Returns(ci => "HASH-" + ci.Arg<string>());
}
private AddNurseBankAccountCommandHandler CreateAddHandler() =>
new(_currentUser, _unitOfWork, _encryptor, _verifier);
[Fact]
public async Task Add_MatchingIban_RunsInquiryAndSetsMatchedTrueAndPrimary()
{
_accounts.IbanHashExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
_accounts.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(false);
_verifier.VerifyOwnershipAsync(Arg.Any<string>(), "0012345678", Arg.Any<CancellationToken>())
.Returns(new OwnershipInquiryResult(true, "Verified Holder", "MOCK-SHEBA-ABC"));
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Result.MatchedNationalId);
Assert.True(result.Result.IsPrimary);
Assert.DoesNotContain(ValidIban, result.Result.IbanMasked);
await _verifier.Received(1).VerifyOwnershipAsync(ValidIban, "0012345678", Arg.Any<CancellationToken>());
await _accounts.Received(1).AddAsync(
Arg.Is<NurseBankAccount>(a => a.NurseId == 42L && a.MatchedNationalId == true && a.OwnershipVendorRef == "MOCK-SHEBA-ABC" && a.IbanHash == "HASH-" + ValidIban),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Add_MismatchIban_RecordsMatchedFalse()
{
_accounts.IbanHashExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
_accounts.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(true);
_verifier.VerifyOwnershipAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new OwnershipInquiryResult(false, "Someone Else", "MOCK-SHEBA-XYZ"));
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Result.MatchedNationalId);
Assert.False(result.Result.IsPrimary);
}
[Fact]
public async Task Add_DuplicateIban_IsRejectedBeforeInsert()
{
_accounts.IbanHashExistsAsync("HASH-" + ValidIban, Arg.Any<CancellationToken>()).Returns(true);
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.False(result.IsSuccess);
await _accounts.DidNotReceive().AddAsync(Arg.Any<NurseBankAccount>(), Arg.Any<CancellationToken>());
await _verifier.DidNotReceive().VerifyOwnershipAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Add_NoNurseProfile_IsFailure()
{
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.False(result.IsSuccess);
await _accounts.DidNotReceive().AddAsync(Arg.Any<NurseBankAccount>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task SetPrimary_OwnedNonPrimary_FlipsAtomically()
{
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_accounts.GetOwnedAsync(5L, 42L, Arg.Any<CancellationToken>()).Returns(new NurseBankAccount { NurseId = 42L, IsPrimary = false });
var handler = new SetPrimaryBankAccountCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetPrimaryBankAccountCommand(5L), CancellationToken.None);
Assert.True(result.IsSuccess);
await _accounts.Received(1).SetPrimaryAsync(42L, 5L, Arg.Any<CancellationToken>());
}
[Fact]
public async Task SetPrimary_NotOwned_IsNotFound()
{
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_accounts.GetOwnedAsync(5L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new SetPrimaryBankAccountCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetPrimaryBankAccountCommand(5L), CancellationToken.None);
Assert.True(result.IsNotFound);
await _accounts.DidNotReceive().SetPrimaryAsync(Arg.Any<long>(), Arg.Any<long>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,83 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class NurseProfileHandlersTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly INurseProfileRepository _repo = Substitute.For<INurseProfileRepository>();
public NurseProfileHandlersTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_unitOfWork.NurseProfileRepository.Returns(_repo);
}
[Fact]
public async Task Upsert_NoExistingProfile_CreatesUnverifiedAndCommits()
{
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
_repo.GetMineAsync(7, Arg.Any<CancellationToken>())
.Returns(new NurseProfileDto(1, "bio", 3, "BSc", "Nursing", "[]", false, false, 0m, 0, 0));
var handler = new UpsertNurseProfileCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new UpsertNurseProfileCommand("bio", 3, "BSc", "Nursing", "[]"), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Result.IsVerified);
await _repo.Received(1).AddAsync(
Arg.Is<NurseProfile>(p => p.UserId == 7 && !p.IsVerified && !p.IsAcceptingBookings),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Upsert_NonNurseRole_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Customer]);
var handler = new UpsertNurseProfileCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new UpsertNurseProfileCommand("bio", 3, "BSc", "Nursing", "[]"), CancellationToken.None);
Assert.True(result.IsForbidden);
await _repo.DidNotReceive().AddAsync(Arg.Any<NurseProfile>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task SetAcceptingBookings_NoProfile_IsNotFound()
{
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
Assert.True(result.IsNotFound);
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task SetAcceptingBookings_ExistingProfile_TogglesWithoutTouchingVerified()
{
var profile = new NurseProfile { UserId = 7 };
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(profile.IsAcceptingBookings);
Assert.False(profile.IsVerified);
await _unitOfWork.Received(1).CommitAsync();
}
}
@@ -0,0 +1,101 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity.Commands.CreatePatient;
using Baya.Application.Features.Identity.Commands.UpdatePatient;
using Baya.Application.Features.Identity.Queries.GetPatient;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class PatientHandlersTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
private readonly IPatientRepository _patients = Substitute.For<IPatientRepository>();
public PatientHandlersTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Customer]);
_unitOfWork.CustomerProfileRepository.Returns(_customers);
_unitOfWork.PatientRepository.Returns(_patients);
}
[Fact]
public async Task Create_UnderExistingCustomer_UsesResolvedCustomerId()
{
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("female", result.Result.Gender);
await _patients.Received(1).AddAsync(Arg.Is<Patient>(p => p.CustomerId == 42L && p.IsActive), Arg.Any<CancellationToken>());
await _customers.DidNotReceive().AddAsync(Arg.Any<CustomerProfile>(), Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Create_NoCustomerProfileYet_AutoProvisionsProfile()
{
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns((long?)null);
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
CancellationToken.None);
Assert.True(result.IsSuccess);
await _customers.Received(1).AddAsync(Arg.Is<CustomerProfile>(c => c.UserId == 7), Arg.Any<CancellationToken>());
await _patients.Received(1).AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_NonCustomerRole_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Nurse]);
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
CancellationToken.None);
Assert.True(result.IsForbidden);
await _patients.DidNotReceive().AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Update_OtherCustomersPatient_IsNotFound()
{
// Tenancy: the repo scopes by customerId, so a non-owned patient resolves to null → not-found.
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_patients.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new UpdatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new UpdatePatientCommand(99L, "X", "A", "B", new DateOnly(1960, 5, 5), "male", null, null),
CancellationToken.None);
Assert.True(result.IsNotFound);
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Get_OtherCustomersPatient_IsNotFound()
{
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_patients.GetOwnedProjectedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new GetPatientQueryHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new GetPatientQuery(99L), CancellationToken.None);
Assert.True(result.IsNotFound);
}
}