@
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user