Test cases
Test cases (CAP RPL)
Section titled “Test cases (CAP RPL)”Audience: qa, product
Status: specced
Owns: QA
Depends on: Lifecycle, Policies, Experience flows
Format follows the contributing test-case template. Expand as features land; keep codes stable.
Happy path
Section titled “Happy path”ID: TC-HP-01Given: Candidate with verified NIN; Application DRAFT / AWAITING_ACTIONWhen: Candidate submits application with trade/unitsThen: Stage becomes CENTRE_REVIEW; status AWAITING_ACTION (or equivalent awaiting reviewer)Refs: lifecycle happy path · INV-01ID: TC-HP-02Given: Application CENTRE_REVIEW; centre acceptsWhen: Candidate completes PAYMENTThen: Facilitator may be assigned; EVIDENCE can proceedRefs: Payment module · INV-03ID: TC-HP-03Given: Application through INTERVIEW with successful panel outcome and verificationsWhen: Awarding path completes CERTIFICATIONThen: Certificate issued/collectable per policy; application reaches terminal success pathRefs: lifecycle · POL-06Attention required
Section titled “Attention required”ID: TC-ATT-01Given: Application CENTRE_REVIEW / ATTENTION_REQUIREDWhen: Candidate amends and resubmits per feedbackThen: Application returns to centre review awaiting action (not a second concurrent active path)Refs: lifecycle · experience centre/candidate flowsAppeal
Section titled “Appeal”ID: TC-APL-01Given: Application INTERVIEW with outcome candidate disputesWhen: Candidate opens appealThen: Status APPEAL_OPEN; discussion chat available with facilitator/panel as designedRefs: lifecycle · personas CandidateGap training
Section titled “Gap training”ID: TC-GAP-01Given: Interview unsuccessful with competency gapsWhen: System closes application path for gapsThen: Stage CLOSED with reason GAP_TRAINING; LMS recommendations visible to candidate (and panelists when relevant)Refs: Recommendations · INV-06 · LMS overviewConcurrent application
Section titled “Concurrent application”ID: TC-CON-01Given: Candidate has one active non-draft ApplicationWhen: Candidate attempts to start another application pathThen: Action blocked until policy allows (feedback/ATTENTION_REQUIRED handling or POL-01 silent-centre days)Refs: lifecycle concurrent rule · POL-01 · INV-02Assessor conflict
Section titled “Assessor conflict”ID: TC-EV-01Given: User is an assessor at Centre AWhen: Assignment as EV for an application at Centre A is attemptedThen: Assignment rejected / preventedRefs: POL-10 · INV-05Identity gate
Section titled “Identity gate”ID: TC-ID-01Given: Application DRAFT / IN_PROGRESS; NIN not verifiedWhen: Candidate attempts submitThen: Submit blocked; remains in draft until identity verifiedRefs: Identity module · INV-01Centre dashboard
Section titled “Centre dashboard”ID: TC-CD-01Given: Centre super_admin with wallet.read; applications paid in UTC year YWhen: GET /centre/dashboard?year=Y&applicationType=RPLThen: KPIs include net revenue; revenueByMonth present; gender counts only; stages from latest RPL workflow (legacy keys in other)Refs: Centres · Payment wallet creditID: TC-CD-02Given: Centre regular_admin or staff (no wallet.read)When: GET /centre/dashboardThen: 200; revenue and revenueByMonth omitted (not zeroed); other KPIs presentRefs: RBAC · CentresDirectory and chat
Section titled “Directory and chat”ID: TC-DIR-01Given: Centre staffWhen: GET /directory?q=&kinds=staff,candidate,assessorThen: Results scoped to own centre; assessor kind is approved retained only; userId is Orchestrator User.idRefs: Centres directoryID: TC-CHAT-01Given: Authenticated user (any persona) with directory hitsWhen: POST /conversations then POST /conversations/{id}/messages (reply)Then: Thread stored on Orchestrator; reply succeeds for any participant; email/SMS not sent unless policy allow-list enables those channelsRefs: Orchestrator conversationsCentre staff (multi-hat, invite, headers)
Section titled “Centre staff (multi-hat, invite, headers)”ID: TC-ST-01Given: User with completed candidate onboarding and a CentreStaff row; staff onboarding draftWhen: GET /onboarding/mine and GET /meThen: mine lists candidate completed + centre variant staff draft; me.centres[].status is onboarding_required; candidate hat operableRefs: Onboarding · AccountID: TC-ST-02Given: Dual-hat user (candidate + operable centre staff); no X-CAP-PERSONAWhen: GET /applicationsThen: 400 (more than one operable hat)Refs: Acting context · ApplicationsID: TC-ST-03Given: Dual-hat user; X-CAP-PERSONA=candidateWhen: GET /applicationsThen: Only that user's candidate applications (not the centre queue)Refs: Acting contextID: TC-APP-01Given: Authenticated caller who can view an application; candidate CapUser has photoAssetId; another application whose candidate has no photoWhen: GET /applications; GET /applications/{id}Then: Each Application.candidate includes photoAssetId and photo { assetId, url } (url null if resolve missed). Missing photo → both null. Nested centre/admin/assessor application lists match. Unauthenticated → 401Refs: Applications · candidate photoID: TC-APP-02Given: Authenticated caller who can view an application; a current FacilitatorAssignment exists (revokedAt null); the assessor CapUser has photoAssetId; another application with no facilitator or only a revoked assignmentWhen: GET /applications; GET /applications/{id}Then: Each Application.facilitator is { assessorId, name, photoAssetId, photo { assetId, url }, assignedAt } or null. Name is onboarding first+last else assessorNo. Missing photo → photoAssetId and photo null. Nested centre/admin/assessor application lists match. Unauthenticated → 401Refs: Applications · facilitatorID: TC-ST-04Given: User is staff at two centres; X-CAP-PERSONA=centre; no X-CAP-CENTRE-IDWhen: GET /centre/dashboardThen: 400Refs: Acting context · CentresID: TC-ST-05Given: Existing OL user (completed candidate onboarding); centre super_admin POSTs /centre/staff with that emailWhen: Invite succeedsThen: No generated password; “added to centre” mail uses registered name; CentreStaff pending at this centre; staff onboarding draft created if missing; membership at another centre is allowedRefs: Centres invite · ProvisionAccountID: TC-ST-06Given: Unknown email; centre super_admin POSTs /centre/staffWhen: ProvisionAccount creates the userThen: OL mails generated password in-process (not on notification.requested); login User.mustChangePassword true; CAP stores real userId (not invite:email)Refs: ProvisionAccount · AuthID: TC-ST-07Given: Invited staff with identityVerified from candidate onboardingWhen: POST /onboarding/centre/submit with staff variant payload (terms accepted)Then: 200 completed without a second NIN; all CentreStaff rows for that user may become activeRefs: Onboarding staff variantID: TC-ST-08Given: Centre super_admin; staff with completed onboardingWhen: PATCH /centre/staff { ids, status: inactive }Then: Those members are inactive at this centre; pending cannot be set by the clientRefs: Centre staff bulkCentre applications queue, jobs, retained requests
Section titled “Centre applications queue, jobs, retained requests”ID: TC-CQ-01Given: Dual-hat user; X-CAP-PERSONA=centre; X-CAP-CENTRE-ID setWhen: GET /applications?stage=interview&sort=submittedAtThen: Only applications at that centre; unknown stage → 422; drafts sort last (null submittedAt)Refs: Applications list · Acting contextID: TC-CQ-02Given: Centre with drafts, in_progress on application_form, later stages, certified, rejected, withdrawnWhen: GET /centre/applications/summaryThen: pending / ongoing / completed / archived match those buckets; total is their sum; drafts excluded. Candidate GET /applications/summary still returns active/completedRefs: Centres applications summaryID: TC-IV-01Given: Centre staff; application with IV assignedWhen: GET /applications/{id}Then: internalVerifier has assessorId, name, qualifications, assignedAt. GET /centre/assessors?qualification=IV lists approved retained IVs onlyRefs: IV picker · Centres assessorsID: TC-INT-01Given: RPL interview stage; panel curated; centre has no addressWhen: POST /applications/{id}/interview/schedule { mode: physical, useCentreAddress: true }Then: 422. GET /centre/profile address is null. Providing location succeeds; GET …/interview/schedule returns mode/locationRefs: Interview schedule · Centre profileID: TC-INT-02Given: RPL interview stage; caller is voting lead panelist; form unsignedWhen: PUT /applications/{id}/interview/forms/records { data: { … } }Then: 200 InterviewForm with updated data; status remains draft until candidate signoffRefs: Lead interview form writeID: TC-INT-03Given: Same panel; non-lead voting panelist; or form already signed by candidateWhen: PUT /applications/{id}/interview/forms/records { data: { … } }Then: Non-lead → 403; signed form → 409 interview.form_already_signedRefs: Lead interview form write guardsID: TC-INT-04Given: RPL interview; all voting members evaluating; caller is leadWhen: POST /applications/{id}/interview/evaluate { feedback, signatureAssetId, decision: approve } or { …, decision: reject, outcome: unsuccessful }Then: 200; reject+outcome seeds GAP_TRAINING path when strategy completes; non-lead sending decision → 403Refs: Interview evaluate lead decisionID: TC-INT-05Given: Centre staff; three approved assessors; one of them (or a fourth) is IV-qualified; acting centreWhen: POST /centre/panels (distinct IV, and observerIvAssessorId equal to one assessorId); GET /centre/panels; PATCH members; DELETE while an interview template references itThen: Create/list return members with name, email, photoAssetId/photo (null if none). Dual-role IV is three member rows (that voter has isObserver). Distinct IV is four rows. Two assessorIds still 422. Delete in-use → 409Refs: Centre panel templatesID: TC-INT-06Given: Centre interview template with scheduledAt and panelId; RPL application at interview stage; a second applicationWhen: GET /centre/interviews?from=&to=; POST /centre/interviews/{id}/schedule { applicationId }; PATCH template scheduledAt; GET application scheduleThen: List returns templates in the datetime window (unscheduled omitted). Copy returns centreInterviewId + seated panel photos. Template PATCH does not change the copied application scheduledAt. Second copy on same app → 409. Unauthenticated → 401Refs: Centre interview templates · copy-on-assignID: TC-INT-07Given: Centre staff; a seated InterviewSchedule (template copy or ad-hoc) with candidate photo/email; another centre's bookingWhen: GET /centre/interview-bookings?from=&to=; GET /centre/interview-bookings/{id}; PATCH { scheduledAt }; PATCH the template sittingThen: List returns this centre's bookings with application, candidate name/email/photo, panel members. PATCH updates the application sitting only. Template PATCH does not change the booking. Other centre's id → 404. Unauthenticated → 401Refs: Centre interview bookings diaryID: TC-INT-08Given: Centre staff; interview template with scheduledAt; two RPL in-progress apps at folder_arrangement with no sitting; one already scheduled; one NSQWhen: GET /applications?interviewSchedulable=true; same flag with stage=interview; POST /centre/interviews/{id}/schedule { items } with equal and earlier times; evaluate/PUT forms on a folder-arrangement copyThen: Picker returns the unscheduled folder-arrangement apps only. Flag + stage → 422. Bulk copy succeeds when scheduledAt ≥ template clock (equality OK); earlier time → 422 and no stamps. Duplicate ids → 422. Second copy → 409. Evaluate / PUT forms while still on folder_arrangement → 409 interview.wrong_stageRefs: Bulk interview booking · schedulable applicationsID: TC-INT-09Given: RPL interview; panel curated before skill_demonstration existed (three form rows)When: GET /applications/{id}/interview/forms; PUT …/forms/skill_demonstration { data }Then: List has four types including skill_demonstration (backfill). Lead PUT succeeds while unsigned; candidate signoff uses the same path as the other formsRefs: Fourth interview form · ensureInterviewFormsID: TC-IV-01Given: Centre staff; apps with IV StageHistory APPROVED now at external_verification; one still in interviewWhen: GET /applications?ivApproved=true&status=in_progressThen: Only IV-approved in-progress apps. stage=internal_verification does not return themRefs: IV approved list filterID: TC-SHARE-01Given: Centre staff of the application's centre; IV APPROVED; no token yetWhen: POST /applications/{id}/share-token; GET /shared/applications/{token} without Bearer; POST again; DELETE; GET with old token; GET …/assets/{foreignAssetId}Then: 201 returns plaintext once. Public GET 200 dossier (no NIN/payment). Second POST replaces. DELETE then GET → 404. Foreign assetId → 403. Before IV → 409 verification.iv_not_approvedRefs: Application share tokenID: TC-CERT-01Given: Centre staff; CAP_CENTRE_CERTIFY_BYPASS off then on; IV-approved in_progress app; already CERTIFIED app; other centre's appWhen: PATCH /centre/applications/bulk { ids, action: "certify" }Then: Flag off → 404 feature.centre_certify_disabled. Flag on: IV-approved → updated (status CERTIFIED, EV+certification history APPROVED, currentStageKey certification). Already certified → skipped. Wrong centre → failed. No Certificate rowRefs: Centre certify bypassID: TC-JP-01Given: Open posting with slot=1; two applied applicantsWhen: PATCH …/applications/{id} { decision: shortlist }Then: That row is accepted; posting status filled; the other remains applied; marketplace hides the posting. Close uses { status: closed } onlyRefs: Job postings occupancyID: TC-JP-02Given: Posting with at least one JobPostingApplicationWhen: DELETE /centre/job-postings/{id}Then: 409. Zero applicants → 200 deletedRefs: Job postings deleteID: TC-RR-01Given: Pending retained requests at the acting centreWhen: GET /centre/retained-requests?q=name and GET /centre/retained-requests/{id}Then: Assessor snapshot includes name, email, qualifications, sectors (not trade), certificates. PATCH bulk { decision: approve } skips non-pending idsRefs: Retained requestsID: TC-AR-01Given: Centre with pending, approved, revoked, and rejected retained requestsWhen: GET /centre/assessors/summary and GET /centre/assessors (no status)Then: Cards: active=approved, pending=pending, inactive=revoked, total=those three (rejected excluded). List default is approved only so IV picker is unchangedRefs: Centre assessor rosterID: TC-AR-02Given: Approved retained assessor with in_progress and certified assignments at this centreWhen: GET /centre/assessors/{id} and GET /centre/assessors/{id}/applicationsThen: workload.assigned = ongoing + completed; applications include roles and assignedAt. Other centre → 404. No edit-profile or delete-qualificationRefs: Centre assessor detailID: TC-AR-03Given: Approved retained request at the acting centreWhen: PATCH /centre/retained-requests/{id}/revokeThen: 200; status=revoked; appears on GET /centre/assessors?status=revoked. Pending/rejected → 409Refs: Retained revokeAssessor console
Section titled “Assessor console”ID: TC-AS-01Given: Approved assessor with 2 approved retained centres; 1 IN_PROGRESS + 1 CERTIFIED assignmentWhen: GET /assessor/summaryThen: totalCentres=2; totalApplications=2; pendingApplications=1; completedApplications=1Refs: Assessor summaryID: TC-AS-02Given: Assessor with active assignmentsWhen: GET /assessor/applicationsThen: Each row is CentreAssessorApplication (roles, assignedAt); supports q / tradeId / type / status filtersRefs: Assessor applicationsID: TC-AS-03Given: Assessor with retained centres and assignmentsWhen: GET /assessor/centres?status=approved&q=nameThen: Rows include centreName, roles[], assignedCount, joinedAt, preferredRole; searchable/sortableRefs: Assessor centresID: TC-AS-04Given: Assessor approved at centre A; rejected or missing at centre BWhen: GET /assessor/centres/{A}/applications and GET /assessor/centres/{B}/applicationsThen: A returns history apps with roles; B → 404 assessor.centre_not_foundRefs: Assessor centre applicationsID: TC-AS-05Given: Approved assessorWhen: POST /assessor/retained-requests { centreId, preferredRole: "facilitator" }Then: 201 with preferredRole; omitted preferredRole → 422. Not-approved profile → assessor.not_approvedRefs: Retained apply preferredRoleID: TC-AS-06Given: Assessor with an active assignment that has interview ApplicationEvents; another application with events but only a revoked assignmentWhen: GET /assessor/eventsThen: Returns only events from actively assigned apps, ordered by eventAt asc; empty when the caller has no active assignmentsRefs: Assessor events calendarCentre payments and settings
Section titled “Centre payments and settings”ID: TC-PAY-01Given: Centre super_admin; wallet credits and applications with payment pending/completed/failedWhen: GET /centre/payments/summary and GET /centre/payments?status=pendingThen: totalRevenue is all-time net wallet credits (not dashboard UTC year); pendingCount includes unpaid and failed; list rows have candidateName, amount, receiptAvailableRefs: Centres payments · Payment moduleID: TC-PAY-02Given: Centre regular_admin or staffWhen: GET /centre/payments or GET /centre/pricingThen: 403 (wallet.read / pricing.read are super_admin only)Refs: RBACID: TC-PAY-03Given: Application with payment.completed snapshot; receipt PDF not uploadedWhen: GET /applications/{id}/receiptThen: 200 with paidAt, provider, amount, candidateName; url may be null; does not 404 solely for missing PDFRefs: Payment receiptID: TC-PAY-04Given: Platform staff (platform.pricing.read); completed payment with wallet credit; pending and failed checkoutsWhen: GET /admin/payments/summary and GET /admin/paymentsThen: platformRevenue = fee slice on completed; walletDeposits = Σ credits; pendingAmount = pending gross only (failed excluded); list rows have centre, amount (gross), centreNet (null unless completed); GET /applications/{id}/receipt succeeds for platformRefs: Admin payments · Payment moduleID: TC-CFG-01Given: Platform staff with platform.pricing.read and platform.policy.read; seeds for RPL/NSQ floors, fee policy, termsWhen: GET /admin/platform-configThen: 200 with latest RPL/NSQ minimum+version+effectiveFrom, processingFeePercentage, terms content+version+effectiveFromRefs: Admin platform config · Payment moduleID: TC-CFG-02Given: Platform staff with pricing.write + policy.write + terms.write; current RPL v1, NSQ v1, fee v1, terms v1When: PATCH /admin/platform-config { pricing: { RPL: { minimum } }, processingFeePercentage: 10 }; then empty {}Then: RPL and fee versions bump; NSQ and terms versions unchanged; effectiveFrom ≈ now; empty body 422; bad currency / non-positive minimum / fee not integer 0–100 → 422Refs: Admin platform config · Payment moduleID: TC-ADM-01Given: Platform staff (platform.orgs.approve); centre with founding SUPER_ADMIN only; no applications, retained-assessor requests, job postings, or wallet activity (empty wallet allowed)When: DELETE /admin/centres/{id}Then: 200 { message }; Centre row gone; CentreStaff / activity / pricing / empty wallet cascaded; CapUser and OL login remain. Rejected/suspended unused centres also delete. Missing id → 404Refs: Centres module · unused-centre deleteID: TC-ADM-02Given: Platform staff; centre with any of: ≥1 application (incl. draft), retained-assessor request, job posting (postedByType=centre), or wallet with transactions / non-zero balanceWhen: DELETE /admin/centres/{id}Then: 409 centre.in_use; centre and staff unchanged. Extra invited staff alone does not blockRefs: Centres module · centre.in_useID: TC-ADM-03Given: Platform staff; two unused centres A,B and one in-use centre CWhen: PATCH /admin/centres/bulk { ids: [A,B], action: "delete" }; then { ids: [A,C], action: "delete" }; then { ids, status: "suspended" }Then: First call 200 { deleted: 2 }. Second 409 centre.in_use and neither remaining id deleted (all-or-nothing). Status body still suspends. Mixed status+action → 422. Unknown id in delete set → 404 and none deletedRefs: Centres module · admin bulkID: TC-ADM-04Given: Platform staff (platform.orgs.read); submitted RPL and NSQ applications in UTC year Y, some with trade T, one certified, one still on application_formWhen: GET /admin/dashboard?year=Y; then GET /admin/dashboard?year=Y&tradeId=TThen: Payload echoes year and tradeId (null when omitted). Overview + charts use the same cohort (non-draft, submittedAt in Y). Pipeline has seven stages in order with atStage and reached; registered.reached equals pipeline.total; sum(atStage) ≤ total. Top trades sorted count desc. Second call only includes trade T. Unauthenticated → 401Refs: Admin dashboard · Centres moduleID: TC-ADM-05Given: Platform staff (platform.orgs.read); centres in pending, approved, suspended, and rejectedWhen: GET /admin/centres/summaryThen: 200 { total, active, pending, inactive } with active=approved, pending=pending, inactive=suspended only, total=pending+approved+suspended (rejected excluded). GET /admin/centres?status=rejected still lists rejected. Unauthenticated → 401Refs: Centres module · admin centres summaryID: TC-ADM-06Given: Platform staff (platform.orgs.approve); unused assessor (no facilitator/panel/NSQ/IV/EV assignments, retained requests, job applications, or direct-observation sessions); a second assessor with any of those; two unused ids A,BWhen: DELETE /admin/assessors/{id} on unused; then on in-use; then PATCH /admin/assessors/bulk { ids: [A,B], action: "delete" }; then { ids: [A, in-use], action: "delete" }; then { ids, status: "suspended" }Then: Unused 200 { message }; AssessorProfile + sector experience gone; CapUser / onboarding / OL login remain. In-use 409 assessor.in_use and row unchanged. Bulk unused 200 { deleted: 2 }. Mixed set 409 and none deleted. Status body still suspends. Missing id → 404. Mixed status+action → 422Refs: Assessors module · unused-assessor deleteID: TC-ADM-07Given: Platform staff (platform.orgs.read); centres with and without a founding SUPER_ADMIN (with photoAssetId); assessors with distinct createdAtWhen: GET /admin/centres; GET /admin/centres/{id}; GET /admin/assessorsThen: Centre list/detail include admin { name, email, photoAssetId, photo } or admin null. Owner photo is distinct from centre logo. Both lists are createdAt desc then id desc (newest first). Unauthenticated → 401Refs: Centres module · Assessors module · admin owner + sortID: TC-ADM-08Given: Platform staff (platform.orgs.read); assessor with completed onboarding personalDetails; another with no assessor onboardingWhen: GET /admin/assessors/{id}; GET /admin/assessorsThen: Detail includes personalDetails { firstName, lastName, middleName, dob, gender, nationality } (nulls for missing keys; whole object null when onboarding/personalDetails absent). List items omit personalDetails. Unauthenticated → 401Refs: Assessors module · admin assessor personalDetailsID: TC-SET-01Given: Centre super_admin; completed organisation onboardingWhen: PATCH /centre/profile { name, logoAssetId, address, supportContact, accountDetails }; body includes registrationNoThen: 200 updates Centre + owner organisation blob; registrationNo extra field 422 (strict body). GET /onboarding/centre still matchesRefs: Centres profileID: TC-SET-02Given: Centre super_admin; platform floor 15000000 koboWhen: PUT /centre/pricing { items: [ { RPL at/above floor }, { NSQ below floor } ] }Then: 422; neither price row writtenRefs: Centre pricing batchID: TC-SET-03Given: Approved centre with logoAssetId; catalogue, admin list, and admin detail callersWhen: GET /centres, GET /admin/centres, GET /admin/centres/{id}, GET /centre/profileThen: Each payload includes logoAssetId and logo { assetId, url } (url null if resolve missed). PATCH /centre/profile still accepts logoAssetId only (no logo object on write)Refs: Centres logo resolve · Account assetsID: TC-ME-01Given: identityVerified user; completed onboardingWhen: PATCH /me/profile { personalDetails.firstName }Then: 422 identity_fields_locked. Contact/address/occupation/accessibility/photoAssetId patches succeed. GET /me includes identityVerifiedRefs: Identity field locks · AccountID: TC-ME-02Given: Sole active centre super_adminWhen: GET /me/deletion-eligibilityThen: canDelete false; blockers includes last_centre_super_admin. OL POST /auth/delete-account still allowed; CAP user.deleted anonymizes PII and inactivates memberships without deleting the CentreRefs: Auth delete · user.deletedID: TC-ME-03Given: Candidate with completed candidate onboardingWhen: PATCH /me/profile { currentOccupation, accessibility }; then PATCH /onboarding/candidate/saveThen: 200 merges occupation/accessibility into the candidate onboarding blob (GET /me/profile and nested GET /candidate/profile.onboarding.data match). Onboarding save still 409Refs: Account profile · Onboarding completed lockID: TC-ME-04Given: User with no Candidate onboarding record (staff-only or assessor-only)When: PATCH /me/profile { currentOccupation } or { accessibility }Then: 422 profile.candidate_onboarding_required. Contact/address/photoAssetId still succeed when otherwise validRefs: Account profile · candidate onboarding requiredID: TC-ME-05Given: Authenticated user; asset uploaded via OL POST /storage/uploadWhen: PATCH /me/profile { photoAssetId }; then PATCH { photoAssetId: null }Then: GET returns photoAssetId plus photo { assetId, url } (url null if resolve missed); null clears both. user.deleted anonymize clears photoAssetIdRefs: Account photo · ResolvedAssetID: TC-PROF-01Given: Candidate with Candidate row + onboarding; assessor with assessor onboarding; centre with founding super_admin org onboardingWhen: GET /candidate/profile, GET /assessor/profile, GET /centre/profileThen: Each nests onboarding (OnboardingRecord or null). Dashboard fields are not a flattened copy of the blob. Nested data stays id-only (no resolved URLs inside). GET /candidate/profile/signature.url is resolved. Notification prefs are not on these payloadsRefs: Persona profile nest · OnboardingID: TC-PREF-01Given: User with email preference falseWhen: OL dispatch template cap.application.submitted channel emailThen: Skipped (no inbox/email). Same user auth.password_reset still deliversRefs: Orchestrator notification preferences