backend phase 6: nurse verification & credentials (mocked vendors)
The trust engine. New `verif` schema (5 tables) + a data-driven verification pipeline: steps are rows (6 seeded step-types), not a code enum. - nurse_verifications.status is the single source of verification truth; nurse_profiles.is_verified is flipped ONLY inside the finalize transaction (VerificationAggregator: tracked verification + tracked profile -> one commit) and reversed on suspension/expiry — no in-between state. - is_automated snapshotted onto each step at submit; steps seeded from active required step-types; automated runs (identity-KYC, Shahkar, IBAN ownership) find their step by code. - users.national_id populated only on identity-KYC pass; Shahkar + IBAN owner compare against it (money-mule guard); shared-SIM -> shared_sim support alert. - Documents are metadata-only behind signed URLs; credential_number encrypted and never serialized; public trust badge exposes credential TYPES, not numbers; holder-name cross-checked against the verified identity before recording. - Admin-triggered credential-expiry scan reverts lapsed steps, re-gates bookability, raises a verification_expired alert + verification_expiry_prompt notification (scheduled cron deferred; config key verification_expiry_scan_cadence_hours). Three new mock vendor seams (IShahkarVerifier / IIdentityKycProvider / ICredentialVerifier) behind DI; reuses b3 IBankAccountOwnershipVerifier and b0 IObjectStorage/IFieldEncryptor. 15 endpoints across 4 controllers. Two migrations (tables + step-type seed). 154 tests pass, zero new warnings. Contract dev/contracts/domains/verification.md + swagger snapshot refreshed; handoff/report/mocks-registry updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
using Baya.Application.Contracts.Audit;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Features.Verification.Commands.ReviewStep;
|
||||
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
|
||||
using Baya.Application.Features.Verification.Commands.SuspendVerification;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
|
||||
|
||||
namespace Baya.Test.Foundation.Verification;
|
||||
|
||||
public class AdminVerificationHandlersTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IVerificationRepository _verif = Substitute.For<IVerificationRepository>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly ICacheService _cache = Substitute.For<ICacheService>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
|
||||
public AdminVerificationHandlersTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(99);
|
||||
_unitOfWork.VerificationRepository.Returns(_verif);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_clock.UtcNow.Returns(Now);
|
||||
}
|
||||
|
||||
private static (VerificationStep Step, NurseVerification Verification) MohStepInReview()
|
||||
{
|
||||
var step = Step(5, StepType(3, VerificationStepTypeCodes.MohCompetencyLicense, automated: false), VerificationStepStatus.InReview);
|
||||
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.InReview };
|
||||
verification.Steps.Add(step);
|
||||
step.NurseVerification = verification;
|
||||
return (step, verification);
|
||||
}
|
||||
|
||||
private AdminReviewStepCommandHandler ReviewHandler(ICredentialVerifier credentialVerifier)
|
||||
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock);
|
||||
|
||||
[Fact]
|
||||
public async Task Review_ApproveCredentialStep_RecordsCredentialAndFlipsVerified()
|
||||
{
|
||||
var (step, _) = MohStepInReview();
|
||||
_verif.GetTrackedStepWithVerificationAsync(5, Arg.Any<CancellationToken>()).Returns(step);
|
||||
_verif.GetNurseIdentityNameAsync(42, Arg.Any<CancellationToken>()).Returns("Ali Ahmadi");
|
||||
var profile = new NurseProfile();
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
var credVerifier = Substitute.For<ICredentialVerifier>();
|
||||
credVerifier.VerifyAsync(VerificationStepTypeCodes.MohCompetencyLicense, "LIC-1", Arg.Any<CancellationToken>())
|
||||
.Returns(new CredentialVerificationResult(CredentialVerificationStatus.RequiresManualReview, "manual", null));
|
||||
|
||||
var result = await ReviewHandler(credVerifier).Handle(
|
||||
new AdminReviewStepCommand(5, true, null, "LIC-1", "Ali Ahmadi", "Ministry of Health", null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(VerificationStepStatus.Passed, step.Status);
|
||||
Assert.True(profile.IsVerified);
|
||||
await _verif.Received(1).AddCredentialAsync(
|
||||
Arg.Is<NurseCredential>(c =>
|
||||
c.NurseId == 42
|
||||
&& c.CredentialType == VerificationStepTypeCodes.MohCompetencyLicense
|
||||
&& c.HolderNameSnapshot == "Ali Ahmadi"
|
||||
&& c.VerificationMethod == "manual"
|
||||
&& c.VerifiedByAdminId == 99),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _audit.Received(1).WriteAsync("verification_step", "5", "approve", Arg.Any<IReadOnlyDictionary<string, object?>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Review_HolderNameMismatch_IsRejectedAndRecordsNoCredential()
|
||||
{
|
||||
var (step, _) = MohStepInReview();
|
||||
_verif.GetTrackedStepWithVerificationAsync(5, Arg.Any<CancellationToken>()).Returns(step);
|
||||
_verif.GetNurseIdentityNameAsync(42, Arg.Any<CancellationToken>()).Returns("Ali Ahmadi");
|
||||
var credVerifier = Substitute.For<ICredentialVerifier>();
|
||||
|
||||
var result = await ReviewHandler(credVerifier).Handle(
|
||||
new AdminReviewStepCommand(5, true, null, "LIC-1", "Someone Else", "Ministry of Health", null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(VerificationStepStatus.InReview, step.Status);
|
||||
await _verif.DidNotReceive().AddCredentialAsync(Arg.Any<NurseCredential>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Review_Reject_FailsStepWithReason()
|
||||
{
|
||||
var (step, verification) = MohStepInReview();
|
||||
_verif.GetTrackedStepWithVerificationAsync(5, Arg.Any<CancellationToken>()).Returns(step);
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(new NurseProfile());
|
||||
var credVerifier = Substitute.For<ICredentialVerifier>();
|
||||
|
||||
var result = await ReviewHandler(credVerifier).Handle(
|
||||
new AdminReviewStepCommand(5, false, "Document is illegible", null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(VerificationStepStatus.Failed, step.Status);
|
||||
Assert.Equal("Document is illegible", step.FailureReason);
|
||||
Assert.Equal("Document is illegible", verification.RejectionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspend_ReversesVerifiedInSameTransaction()
|
||||
{
|
||||
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved };
|
||||
verification.Steps.Add(Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Passed));
|
||||
_verif.GetTrackedByIdAsync(10, Arg.Any<CancellationToken>()).Returns(verification);
|
||||
var profile = new NurseProfile();
|
||||
profile.MarkVerified();
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock);
|
||||
|
||||
var result = await handler.Handle(new AdminSuspendVerificationCommand(10, "Fraud reported"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(VerificationStatus.Suspended, verification.Status);
|
||||
Assert.Equal(Now, verification.SuspendedAt);
|
||||
Assert.False(profile.IsVerified);
|
||||
await _cache.Received(1).RemoveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_ExpiredCriminalRecord_RevertsStepRaisesAlertAndNotifies()
|
||||
{
|
||||
var bank = Step(4, StepType(4, VerificationStepTypeCodes.BankAccountVerification, automated: true), VerificationStepStatus.Passed);
|
||||
var criminal = Step(5, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed);
|
||||
criminal.ExpiresAt = Now.AddDays(-1);
|
||||
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved };
|
||||
verification.Steps.Add(bank);
|
||||
verification.Steps.Add(criminal);
|
||||
|
||||
_verif.GetExpiredPassedStepsAsync(Now, 1, 50, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ExpiringStepRow> { new(5, 10, 42) });
|
||||
_verif.GetTrackedByIdAsync(10, Arg.Any<CancellationToken>()).Returns(verification);
|
||||
var profile = new NurseProfile { UserId = 7 };
|
||||
profile.MarkVerified();
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
var notifications = Substitute.For<INotificationDispatcher>();
|
||||
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock);
|
||||
|
||||
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Result.RevertedNurses);
|
||||
Assert.Equal(VerificationStepStatus.Expired, criminal.Status);
|
||||
Assert.False(profile.IsVerified);
|
||||
await alerts.Received(1).RaiseAsync(
|
||||
SupportAlertType.VerificationExpired, "nurse_profile", "42", SupportAlertSeverity.Medium,
|
||||
Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
|
||||
await notifications.Received(1).DispatchAsync(
|
||||
Arg.Is<Notification>(n => n.RecipientUserId == 7 && n.Type == "verification_expiry_prompt"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_NullProfileNurse_LeavesNoDirtyStepForNextNursesCommit()
|
||||
{
|
||||
// Nurse A's profile is missing (e.g. soft-deleted) while their verification still has an expired
|
||||
// step; nurse B is valid. The guard must run before mutating A's step, so A's step is never left
|
||||
// dirty for B's commit to flush without the atomic re-gate.
|
||||
var stepA = Step(5, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed);
|
||||
stepA.ExpiresAt = Now.AddDays(-1);
|
||||
var verificationA = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved };
|
||||
verificationA.Steps.Add(stepA);
|
||||
|
||||
var stepB = Step(6, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed);
|
||||
stepB.ExpiresAt = Now.AddDays(-1);
|
||||
var verificationB = new NurseVerification { NurseId = 99, Status = VerificationStatus.Approved };
|
||||
verificationB.Steps.Add(stepB);
|
||||
|
||||
_verif.GetExpiredPassedStepsAsync(Now, 1, 50, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ExpiringStepRow> { new(5, 10, 42), new(6, 20, 99) });
|
||||
_verif.GetTrackedByIdAsync(10, Arg.Any<CancellationToken>()).Returns(verificationA);
|
||||
_verif.GetTrackedByIdAsync(20, Arg.Any<CancellationToken>()).Returns(verificationB);
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var profileB = new NurseProfile { UserId = 8 };
|
||||
profileB.MarkVerified();
|
||||
_nurses.GetTrackedByIdAsync(99, Arg.Any<CancellationToken>()).Returns(profileB);
|
||||
|
||||
var handler = new ScanExpiringCredentialsCommandHandler(
|
||||
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock);
|
||||
|
||||
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Result.RevertedNurses);
|
||||
Assert.Equal(VerificationStepStatus.Passed, stepA.Status); // never mutated
|
||||
Assert.Equal(VerificationStepStatus.Expired, stepB.Status);
|
||||
Assert.False(profileB.IsVerified);
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
|
||||
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
|
||||
using Baya.Application.Features.Verification.Commands.RunShahkarMatch;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using NSubstitute;
|
||||
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
|
||||
|
||||
namespace Baya.Test.Foundation.Verification;
|
||||
|
||||
public class RunStepHandlersTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IVerificationRepository _verif = Substitute.For<IVerificationRepository>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
|
||||
public RunStepHandlersTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
_unitOfWork.VerificationRepository.Returns(_verif);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_unitOfWork.NurseBankAccountRepository.Returns(_accounts);
|
||||
_clock.UtcNow.Returns(Now);
|
||||
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(new NurseProfile());
|
||||
}
|
||||
|
||||
private static NurseVerification VerificationWith(VerificationStep step)
|
||||
{
|
||||
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending };
|
||||
verification.Steps.Add(step);
|
||||
return verification;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunIdentityKyc_Pass_PopulatesNationalIdAndPassesStep()
|
||||
{
|
||||
var step = Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending);
|
||||
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
|
||||
var user = new User();
|
||||
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(user);
|
||||
var identityKyc = Substitute.For<IIdentityKycProvider>();
|
||||
identityKyc.VerifyAsync("0012345678", null, Arg.Any<CancellationToken>())
|
||||
.Returns(new IdentityKycResult(true, "Verified Nurse", "ref", "{}", null));
|
||||
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
|
||||
|
||||
var result = await handler.Handle(new RunIdentityKycCommand("0012345678", null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(VerificationStepStatus.Passed, step.Status);
|
||||
Assert.Equal("0012345678", user.NationalId);
|
||||
Assert.Equal(Now, user.NationalIdVerifiedAt);
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunIdentityKyc_Fail_MarksStepFailedAndLeavesNationalIdNull()
|
||||
{
|
||||
var step = Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending);
|
||||
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
|
||||
var user = new User();
|
||||
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(user);
|
||||
var identityKyc = Substitute.For<IIdentityKycProvider>();
|
||||
identityKyc.VerifyAsync("0000000000", null, Arg.Any<CancellationToken>())
|
||||
.Returns(new IdentityKycResult(false, null, "ref", "{}", "could not verify"));
|
||||
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
|
||||
|
||||
var result = await handler.Handle(new RunIdentityKycCommand("0000000000", null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(VerificationStepStatus.Failed, step.Status);
|
||||
Assert.Equal("could not verify", step.FailureReason);
|
||||
Assert.Null(user.NationalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunShahkar_SharedSim_FailsStepAndRaisesAlert()
|
||||
{
|
||||
var step = Step(1, StepType(1, VerificationStepTypeCodes.ShahkarMatch, automated: true), VerificationStepStatus.Pending);
|
||||
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
|
||||
var user = new User { PhoneNumber = "09120000000", NationalId = "0012345678" };
|
||||
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(user);
|
||||
var shahkar = Substitute.For<IShahkarVerifier>();
|
||||
shahkar.MatchAsync("09120000000", "0012345678", Arg.Any<CancellationToken>())
|
||||
.Returns(new ShahkarMatchResult(false, true, "ref", "{}", "shared sim"));
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
|
||||
|
||||
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(VerificationStepStatus.Failed, step.Status);
|
||||
await alerts.Received(1).RaiseAsync(
|
||||
SupportAlertType.SharedSim, "nurse_profile", "42", SupportAlertSeverity.High,
|
||||
Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunShahkar_BeforeIdentityKyc_IsRejected()
|
||||
{
|
||||
var step = Step(1, StepType(1, VerificationStepTypeCodes.ShahkarMatch, automated: true), VerificationStepStatus.Pending);
|
||||
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
|
||||
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(new User { PhoneNumber = "09121112233" });
|
||||
var shahkar = Substitute.For<IShahkarVerifier>();
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
|
||||
|
||||
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await shahkar.DidNotReceive().MatchAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunBankAccount_Mismatch_FailsStepAndRecordsMismatch()
|
||||
{
|
||||
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseIdentityContext(42, "0012345678"));
|
||||
var step = Step(1, StepType(1, VerificationStepTypeCodes.BankAccountVerification, automated: true), VerificationStepStatus.Pending);
|
||||
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
|
||||
var account = new NurseBankAccount { NurseId = 42, Iban = "IR000000000000000000000000" };
|
||||
_accounts.GetPrimaryAsync(42, Arg.Any<CancellationToken>()).Returns(account);
|
||||
var verifier = Substitute.For<IBankAccountOwnershipVerifier>();
|
||||
verifier.VerifyOwnershipAsync(account.Iban, "0012345678", Arg.Any<CancellationToken>())
|
||||
.Returns(new OwnershipInquiryResult(false, "Someone Else", "ref"));
|
||||
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock);
|
||||
|
||||
var result = await handler.Handle(new RunBankAccountVerificationCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(VerificationStepStatus.Failed, step.Status);
|
||||
Assert.False(account.MatchedNationalId);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Verification.Commands.SubmitVerification;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
|
||||
|
||||
namespace Baya.Test.Foundation.Verification;
|
||||
|
||||
public class SubmitNurseVerificationHandlerTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IVerificationRepository _verif = Substitute.For<IVerificationRepository>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
|
||||
public SubmitNurseVerificationHandlerTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
_unitOfWork.VerificationRepository.Returns(_verif);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_clock.UtcNow.Returns(new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseIdentityContext(42, "0012345678"));
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(new NurseProfile());
|
||||
_verif.GetActiveRequiredStepTypesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<VerificationStepType>
|
||||
{
|
||||
StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true),
|
||||
StepType(2, VerificationStepTypeCodes.MohCompetencyLicense, automated: false)
|
||||
});
|
||||
_verif.GetStatusForNurseAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(new VerificationStatusDto("pending", false, ["identity_kyc", "moh_competency_license"], []));
|
||||
}
|
||||
|
||||
private SubmitNurseVerificationCommandHandler Handler() => new(_currentUser, _unitOfWork, _clock);
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_NewVerification_SeedsOneStepPerRequiredType_WithAutomationSnapshot()
|
||||
{
|
||||
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
|
||||
var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _verif.Received(1).AddVerificationAsync(
|
||||
Arg.Is<NurseVerification>(v =>
|
||||
v.NurseId == 42
|
||||
&& v.Status == VerificationStatus.Pending
|
||||
&& v.Steps.Count == 2
|
||||
&& v.Steps.Any(s => s.StepTypeId == 1 && s.IsAutomated)
|
||||
&& v.Steps.Any(s => s.StepTypeId == 2 && !s.IsAutomated)),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_ExistingVerification_IsIdempotent_OnlyAddsMissingSteps()
|
||||
{
|
||||
var existing = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending, SubmittedAt = _clock.UtcNow };
|
||||
existing.Steps.Add(Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending));
|
||||
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
|
||||
var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Only the missing MoH step is added; the identity step is not duplicated.
|
||||
Assert.Equal(2, existing.Steps.Count);
|
||||
Assert.Single(existing.Steps, s => s.StepTypeId == 2);
|
||||
await _verif.DidNotReceive().AddVerificationAsync(Arg.Any<NurseVerification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_NonNurse_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
|
||||
var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsForbidden);
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
|
||||
|
||||
namespace Baya.Test.Foundation.Verification;
|
||||
|
||||
public class VerificationAggregatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static (NurseVerification Verification, NurseProfile Profile) Build(params VerificationStepStatus[] statuses)
|
||||
{
|
||||
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending };
|
||||
for (var i = 0; i < statuses.Length; i++)
|
||||
verification.Steps.Add(Step(i + 1, StepType(i + 1, $"step_{i}", automated: false), statuses[i]));
|
||||
|
||||
return (verification, new NurseProfile());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_AllPassed_ApprovesAndFlipsVerified()
|
||||
{
|
||||
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed);
|
||||
|
||||
var status = VerificationAggregator.Finalize(verification, profile, Now);
|
||||
|
||||
Assert.Equal(VerificationStatus.Approved, status);
|
||||
Assert.Equal(VerificationStatus.Approved, verification.Status);
|
||||
Assert.Equal(Now, verification.ApprovedAt);
|
||||
Assert.True(profile.IsVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_OneFailed_RejectsAndKeepsUnverified()
|
||||
{
|
||||
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Failed);
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, Now);
|
||||
|
||||
Assert.Equal(VerificationStatus.Rejected, verification.Status);
|
||||
Assert.Equal(Now, verification.RejectedAt);
|
||||
Assert.Null(verification.ApprovedAt);
|
||||
Assert.False(profile.IsVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_OneInReview_SetsInReview()
|
||||
{
|
||||
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.InReview);
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, Now);
|
||||
|
||||
Assert.Equal(VerificationStatus.InReview, verification.Status);
|
||||
Assert.False(profile.IsVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_AllPending_StaysPending()
|
||||
{
|
||||
var (verification, profile) = Build(VerificationStepStatus.Pending, VerificationStepStatus.Pending);
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, Now);
|
||||
|
||||
Assert.Equal(VerificationStatus.Pending, verification.Status);
|
||||
Assert.False(profile.IsVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_PreviouslyApproved_ThenExpires_ReversesVerified()
|
||||
{
|
||||
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed);
|
||||
VerificationAggregator.Finalize(verification, profile, Now);
|
||||
Assert.True(profile.IsVerified);
|
||||
|
||||
// A required step lapses (the expiry scan) → re-gate.
|
||||
verification.Steps.Last().Status = VerificationStepStatus.Expired;
|
||||
VerificationAggregator.Finalize(verification, profile, Now);
|
||||
|
||||
Assert.False(profile.IsVerified);
|
||||
Assert.Equal(VerificationStatus.Pending, verification.Status);
|
||||
Assert.Null(verification.ApprovedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_Suspended_StaysSuspendedAndUnverified()
|
||||
{
|
||||
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed);
|
||||
profile.MarkVerified();
|
||||
verification.Status = VerificationStatus.Suspended;
|
||||
|
||||
var status = VerificationAggregator.Finalize(verification, profile, Now);
|
||||
|
||||
Assert.Equal(VerificationStatus.Suspended, status);
|
||||
Assert.False(profile.IsVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_NoSteps_DoesNotApprove()
|
||||
{
|
||||
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending };
|
||||
var profile = new NurseProfile();
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, Now);
|
||||
|
||||
Assert.False(profile.IsVerified);
|
||||
Assert.Equal(VerificationStatus.Pending, verification.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockingStepCodes_ReturnsCodesOfNonPassedSteps()
|
||||
{
|
||||
var (verification, _) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Pending);
|
||||
|
||||
var blocking = VerificationAggregator.BlockingStepCodes(verification.Steps);
|
||||
|
||||
Assert.Single(blocking);
|
||||
Assert.Equal("step_1", blocking[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Baya.Domain.Entities.Verification;
|
||||
|
||||
namespace Baya.Test.Foundation.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the verification handler/aggregator tests. Entity ids have a protected setter (they are
|
||||
/// assigned by EF on save); these set them via reflection so a mocked repository can return graphs with
|
||||
/// stable ids without a real DbContext.
|
||||
/// </summary>
|
||||
internal static class VerificationTestSupport
|
||||
{
|
||||
public static T WithId<T>(this T entity, long id)
|
||||
{
|
||||
var setter = typeof(T).GetProperty("Id")!.GetSetMethod(nonPublic: true)!;
|
||||
setter.Invoke(entity, [id]);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public static VerificationStepType StepType(long id, string code, bool automated, bool required = true)
|
||||
=> new VerificationStepType
|
||||
{
|
||||
Code = code,
|
||||
DisplayName = code,
|
||||
IsAutomated = automated,
|
||||
IsRequired = required,
|
||||
IsActive = true,
|
||||
SortOrder = (int)id
|
||||
}.WithId(id);
|
||||
|
||||
public static VerificationStep Step(long id, VerificationStepType type, VerificationStepStatus status)
|
||||
=> new VerificationStep
|
||||
{
|
||||
StepTypeId = type.Id,
|
||||
StepType = type,
|
||||
Status = status,
|
||||
IsAutomated = type.IsAutomated
|
||||
}.WithId(id);
|
||||
}
|
||||
Reference in New Issue
Block a user