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