Skip to content

Test cases

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.

ID: TC-HP-01
Given: Candidate with verified NIN; Application DRAFT / AWAITING_ACTION
When: Candidate submits application with trade/units
Then: Stage becomes CENTRE_REVIEW; status AWAITING_ACTION (or equivalent awaiting reviewer)
Refs: lifecycle happy path · INV-01
ID: TC-HP-02
Given: Application CENTRE_REVIEW; centre accepts
When: Candidate completes PAYMENT
Then: Facilitator may be assigned; EVIDENCE can proceed
Refs: Payment module · INV-03
ID: TC-HP-03
Given: Application through INTERVIEW with successful panel outcome and verifications
When: Awarding path completes CERTIFICATION
Then: Certificate issued/collectable per policy; application reaches terminal success path
Refs: lifecycle · POL-06
ID: TC-ATT-01
Given: Application CENTRE_REVIEW / ATTENTION_REQUIRED
When: Candidate amends and resubmits per feedback
Then: Application returns to centre review awaiting action (not a second concurrent active path)
Refs: lifecycle · experience centre/candidate flows
ID: TC-APL-01
Given: Application INTERVIEW with outcome candidate disputes
When: Candidate opens appeal
Then: Status APPEAL_OPEN; discussion chat available with facilitator/panel as designed
Refs: lifecycle · personas Candidate
ID: TC-GAP-01
Given: Interview unsuccessful with competency gaps
When: System closes application path for gaps
Then: Stage CLOSED with reason GAP_TRAINING; LMS recommendations visible to candidate (and panelists when relevant)
Refs: Recommendations · INV-06 · LMS overview
ID: TC-CON-01
Given: Candidate has one active non-draft Application
When: Candidate attempts to start another application path
Then: Action blocked until policy allows (feedback/ATTENTION_REQUIRED handling or POL-01 silent-centre days)
Refs: lifecycle concurrent rule · POL-01 · INV-02
ID: TC-EV-01
Given: User is an assessor at Centre A
When: Assignment as EV for an application at Centre A is attempted
Then: Assignment rejected / prevented
Refs: POL-10 · INV-05
ID: TC-ID-01
Given: Application DRAFT / IN_PROGRESS; NIN not verified
When: Candidate attempts submit
Then: Submit blocked; remains in draft until identity verified
Refs: Identity module · INV-01
ID: TC-CD-01
Given: Centre super_admin with wallet.read; applications paid in UTC year Y
When: GET /centre/dashboard?year=Y&applicationType=RPL
Then: KPIs include net revenue; revenueByMonth present; gender counts only; stages from latest RPL workflow (legacy keys in other)
Refs: Centres · Payment wallet credit
ID: TC-CD-02
Given: Centre regular_admin or staff (no wallet.read)
When: GET /centre/dashboard
Then: 200; revenue and revenueByMonth omitted (not zeroed); other KPIs present
Refs: RBAC · Centres
ID: TC-DIR-01
Given: Centre staff
When: GET /directory?q=&kinds=staff,candidate,assessor
Then: Results scoped to own centre; assessor kind is approved retained only; userId is Orchestrator User.id
Refs: Centres directory
ID: TC-CHAT-01
Given: Authenticated user (any persona) with directory hits
When: 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 channels
Refs: Orchestrator conversations
ID: TC-ST-01
Given: User with completed candidate onboarding and a CentreStaff row; staff onboarding draft
When: GET /onboarding/mine and GET /me
Then: mine lists candidate completed + centre variant staff draft; me.centres[].status is onboarding_required; candidate hat operable
Refs: Onboarding · Account
ID: TC-ST-02
Given: Dual-hat user (candidate + operable centre staff); no X-CAP-PERSONA
When: GET /applications
Then: 400 (more than one operable hat)
Refs: Acting context · Applications
ID: TC-ST-03
Given: Dual-hat user; X-CAP-PERSONA=candidate
When: GET /applications
Then: Only that user's candidate applications (not the centre queue)
Refs: Acting context
ID: TC-APP-01
Given: Authenticated caller who can view an application; candidate CapUser has photoAssetId; another application whose candidate has no photo
When: 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 → 401
Refs: Applications · candidate photo
ID: TC-APP-02
Given: 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 assignment
When: 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 → 401
Refs: Applications · facilitator
ID: TC-ST-04
Given: User is staff at two centres; X-CAP-PERSONA=centre; no X-CAP-CENTRE-ID
When: GET /centre/dashboard
Then: 400
Refs: Acting context · Centres
ID: TC-ST-05
Given: Existing OL user (completed candidate onboarding); centre super_admin POSTs /centre/staff with that email
When: Invite succeeds
Then: 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 allowed
Refs: Centres invite · ProvisionAccount
ID: TC-ST-06
Given: Unknown email; centre super_admin POSTs /centre/staff
When: ProvisionAccount creates the user
Then: OL mails generated password in-process (not on notification.requested); login User.mustChangePassword true; CAP stores real userId (not invite:email)
Refs: ProvisionAccount · Auth
ID: TC-ST-07
Given: Invited staff with identityVerified from candidate onboarding
When: 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 active
Refs: Onboarding staff variant
ID: TC-ST-08
Given: Centre super_admin; staff with completed onboarding
When: PATCH /centre/staff { ids, status: inactive }
Then: Those members are inactive at this centre; pending cannot be set by the client
Refs: Centre staff bulk

Centre applications queue, jobs, retained requests

Section titled “Centre applications queue, jobs, retained requests”
ID: TC-CQ-01
Given: Dual-hat user; X-CAP-PERSONA=centre; X-CAP-CENTRE-ID set
When: GET /applications?stage=interview&sort=submittedAt
Then: Only applications at that centre; unknown stage → 422; drafts sort last (null submittedAt)
Refs: Applications list · Acting context
ID: TC-CQ-02
Given: Centre with drafts, in_progress on application_form, later stages, certified, rejected, withdrawn
When: GET /centre/applications/summary
Then: pending / ongoing / completed / archived match those buckets; total is their sum; drafts excluded. Candidate GET /applications/summary still returns active/completed
Refs: Centres applications summary
ID: TC-IV-01
Given: Centre staff; application with IV assigned
When: GET /applications/{id}
Then: internalVerifier has assessorId, name, qualifications, assignedAt. GET /centre/assessors?qualification=IV lists approved retained IVs only
Refs: IV picker · Centres assessors
ID: TC-INT-01
Given: RPL interview stage; panel curated; centre has no address
When: 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/location
Refs: Interview schedule · Centre profile
ID: TC-INT-02
Given: RPL interview stage; caller is voting lead panelist; form unsigned
When: PUT /applications/{id}/interview/forms/records { data: { … } }
Then: 200 InterviewForm with updated data; status remains draft until candidate signoff
Refs: Lead interview form write
ID: TC-INT-03
Given: Same panel; non-lead voting panelist; or form already signed by candidate
When: PUT /applications/{id}/interview/forms/records { data: { … } }
Then: Non-lead → 403; signed form → 409 interview.form_already_signed
Refs: Lead interview form write guards
ID: TC-INT-04
Given: RPL interview; all voting members evaluating; caller is lead
When: 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 → 403
Refs: Interview evaluate lead decision
ID: TC-INT-05
Given: Centre staff; three approved assessors; one of them (or a fourth) is IV-qualified; acting centre
When: POST /centre/panels (distinct IV, and observerIvAssessorId equal to one assessorId); GET /centre/panels; PATCH members; DELETE while an interview template references it
Then: 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 → 409
Refs: Centre panel templates
ID: TC-INT-06
Given: Centre interview template with scheduledAt and panelId; RPL application at interview stage; a second application
When: GET /centre/interviews?from=&to=; POST /centre/interviews/{id}/schedule { applicationId }; PATCH template scheduledAt; GET application schedule
Then: 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 → 401
Refs: Centre interview templates · copy-on-assign
ID: TC-INT-07
Given: Centre staff; a seated InterviewSchedule (template copy or ad-hoc) with candidate photo/email; another centre's booking
When: GET /centre/interview-bookings?from=&to=; GET /centre/interview-bookings/{id}; PATCH { scheduledAt }; PATCH the template sitting
Then: 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 → 401
Refs: Centre interview bookings diary
ID: TC-INT-08
Given: Centre staff; interview template with scheduledAt; two RPL in-progress apps at folder_arrangement with no sitting; one already scheduled; one NSQ
When: 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 copy
Then: 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_stage
Refs: Bulk interview booking · schedulable applications
ID: TC-INT-09
Given: 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 forms
Refs: Fourth interview form · ensureInterviewForms
ID: TC-IV-01
Given: Centre staff; apps with IV StageHistory APPROVED now at external_verification; one still in interview
When: GET /applications?ivApproved=true&status=in_progress
Then: Only IV-approved in-progress apps. stage=internal_verification does not return them
Refs: IV approved list filter
ID: TC-SHARE-01
Given: Centre staff of the application's centre; IV APPROVED; no token yet
When: 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_approved
Refs: Application share token
ID: TC-CERT-01
Given: Centre staff; CAP_CENTRE_CERTIFY_BYPASS off then on; IV-approved in_progress app; already CERTIFIED app; other centre's app
When: 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 row
Refs: Centre certify bypass
ID: TC-JP-01
Given: Open posting with slot=1; two applied applicants
When: 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 } only
Refs: Job postings occupancy
ID: TC-JP-02
Given: Posting with at least one JobPostingApplication
When: DELETE /centre/job-postings/{id}
Then: 409. Zero applicants → 200 deleted
Refs: Job postings delete
ID: TC-RR-01
Given: Pending retained requests at the acting centre
When: 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 ids
Refs: Retained requests
ID: TC-AR-01
Given: Centre with pending, approved, revoked, and rejected retained requests
When: 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 unchanged
Refs: Centre assessor roster
ID: TC-AR-02
Given: Approved retained assessor with in_progress and certified assignments at this centre
When: GET /centre/assessors/{id} and GET /centre/assessors/{id}/applications
Then: workload.assigned = ongoing + completed; applications include roles and assignedAt. Other centre → 404. No edit-profile or delete-qualification
Refs: Centre assessor detail
ID: TC-AR-03
Given: Approved retained request at the acting centre
When: PATCH /centre/retained-requests/{id}/revoke
Then: 200; status=revoked; appears on GET /centre/assessors?status=revoked. Pending/rejected → 409
Refs: Retained revoke
ID: TC-AS-01
Given: Approved assessor with 2 approved retained centres; 1 IN_PROGRESS + 1 CERTIFIED assignment
When: GET /assessor/summary
Then: totalCentres=2; totalApplications=2; pendingApplications=1; completedApplications=1
Refs: Assessor summary
ID: TC-AS-02
Given: Assessor with active assignments
When: GET /assessor/applications
Then: Each row is CentreAssessorApplication (roles, assignedAt); supports q / tradeId / type / status filters
Refs: Assessor applications
ID: TC-AS-03
Given: Assessor with retained centres and assignments
When: GET /assessor/centres?status=approved&q=name
Then: Rows include centreName, roles[], assignedCount, joinedAt, preferredRole; searchable/sortable
Refs: Assessor centres
ID: TC-AS-04
Given: Assessor approved at centre A; rejected or missing at centre B
When: GET /assessor/centres/{A}/applications and GET /assessor/centres/{B}/applications
Then: A returns history apps with roles; B → 404 assessor.centre_not_found
Refs: Assessor centre applications
ID: TC-AS-05
Given: Approved assessor
When: POST /assessor/retained-requests { centreId, preferredRole: "facilitator" }
Then: 201 with preferredRole; omitted preferredRole → 422. Not-approved profile → assessor.not_approved
Refs: Retained apply preferredRole
ID: TC-AS-06
Given: Assessor with an active assignment that has interview ApplicationEvents; another application with events but only a revoked assignment
When: GET /assessor/events
Then: Returns only events from actively assigned apps, ordered by eventAt asc; empty when the caller has no active assignments
Refs: Assessor events calendar
ID: TC-PAY-01
Given: Centre super_admin; wallet credits and applications with payment pending/completed/failed
When: GET /centre/payments/summary and GET /centre/payments?status=pending
Then: totalRevenue is all-time net wallet credits (not dashboard UTC year); pendingCount includes unpaid and failed; list rows have candidateName, amount, receiptAvailable
Refs: Centres payments · Payment module
ID: TC-PAY-02
Given: Centre regular_admin or staff
When: GET /centre/payments or GET /centre/pricing
Then: 403 (wallet.read / pricing.read are super_admin only)
Refs: RBAC
ID: TC-PAY-03
Given: Application with payment.completed snapshot; receipt PDF not uploaded
When: GET /applications/{id}/receipt
Then: 200 with paidAt, provider, amount, candidateName; url may be null; does not 404 solely for missing PDF
Refs: Payment receipt
ID: TC-PAY-04
Given: Platform staff (platform.pricing.read); completed payment with wallet credit; pending and failed checkouts
When: GET /admin/payments/summary and GET /admin/payments
Then: 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 platform
Refs: Admin payments · Payment module
ID: TC-CFG-01
Given: Platform staff with platform.pricing.read and platform.policy.read; seeds for RPL/NSQ floors, fee policy, terms
When: GET /admin/platform-config
Then: 200 with latest RPL/NSQ minimum+version+effectiveFrom, processingFeePercentage, terms content+version+effectiveFrom
Refs: Admin platform config · Payment module
ID: TC-CFG-02
Given: Platform staff with pricing.write + policy.write + terms.write; current RPL v1, NSQ v1, fee v1, terms v1
When: 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 → 422
Refs: Admin platform config · Payment module
ID: TC-ADM-01
Given: 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 → 404
Refs: Centres module · unused-centre delete
ID: TC-ADM-02
Given: Platform staff; centre with any of: ≥1 application (incl. draft), retained-assessor request, job posting (postedByType=centre), or wallet with transactions / non-zero balance
When: DELETE /admin/centres/{id}
Then: 409 centre.in_use; centre and staff unchanged. Extra invited staff alone does not block
Refs: Centres module · centre.in_use
ID: TC-ADM-03
Given: Platform staff; two unused centres A,B and one in-use centre C
When: 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 deleted
Refs: Centres module · admin bulk
ID: TC-ADM-04
Given: Platform staff (platform.orgs.read); submitted RPL and NSQ applications in UTC year Y, some with trade T, one certified, one still on application_form
When: GET /admin/dashboard?year=Y; then GET /admin/dashboard?year=Y&tradeId=T
Then: 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 → 401
Refs: Admin dashboard · Centres module
ID: TC-ADM-05
Given: Platform staff (platform.orgs.read); centres in pending, approved, suspended, and rejected
When: GET /admin/centres/summary
Then: 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 → 401
Refs: Centres module · admin centres summary
ID: TC-ADM-06
Given: 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,B
When: 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 → 422
Refs: Assessors module · unused-assessor delete
ID: TC-ADM-07
Given: Platform staff (platform.orgs.read); centres with and without a founding SUPER_ADMIN (with photoAssetId); assessors with distinct createdAt
When: GET /admin/centres; GET /admin/centres/{id}; GET /admin/assessors
Then: 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 → 401
Refs: Centres module · Assessors module · admin owner + sort
ID: TC-ADM-08
Given: Platform staff (platform.orgs.read); assessor with completed onboarding personalDetails; another with no assessor onboarding
When: GET /admin/assessors/{id}; GET /admin/assessors
Then: 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 → 401
Refs: Assessors module · admin assessor personalDetails
ID: TC-SET-01
Given: Centre super_admin; completed organisation onboarding
When: PATCH /centre/profile { name, logoAssetId, address, supportContact, accountDetails }; body includes registrationNo
Then: 200 updates Centre + owner organisation blob; registrationNo extra field 422 (strict body). GET /onboarding/centre still matches
Refs: Centres profile
ID: TC-SET-02
Given: Centre super_admin; platform floor 15000000 kobo
When: PUT /centre/pricing { items: [ { RPL at/above floor }, { NSQ below floor } ] }
Then: 422; neither price row written
Refs: Centre pricing batch
ID: TC-SET-03
Given: Approved centre with logoAssetId; catalogue, admin list, and admin detail callers
When: GET /centres, GET /admin/centres, GET /admin/centres/{id}, GET /centre/profile
Then: 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 assets
ID: TC-ME-01
Given: identityVerified user; completed onboarding
When: PATCH /me/profile { personalDetails.firstName }
Then: 422 identity_fields_locked. Contact/address/occupation/accessibility/photoAssetId patches succeed. GET /me includes identityVerified
Refs: Identity field locks · Account
ID: TC-ME-02
Given: Sole active centre super_admin
When: GET /me/deletion-eligibility
Then: 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 Centre
Refs: Auth delete · user.deleted
ID: TC-ME-03
Given: Candidate with completed candidate onboarding
When: PATCH /me/profile { currentOccupation, accessibility }; then PATCH /onboarding/candidate/save
Then: 200 merges occupation/accessibility into the candidate onboarding blob (GET /me/profile and nested GET /candidate/profile.onboarding.data match). Onboarding save still 409
Refs: Account profile · Onboarding completed lock
ID: TC-ME-04
Given: 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 valid
Refs: Account profile · candidate onboarding required
ID: TC-ME-05
Given: Authenticated user; asset uploaded via OL POST /storage/upload
When: 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 photoAssetId
Refs: Account photo · ResolvedAsset
ID: TC-PROF-01
Given: Candidate with Candidate row + onboarding; assessor with assessor onboarding; centre with founding super_admin org onboarding
When: GET /candidate/profile, GET /assessor/profile, GET /centre/profile
Then: 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 payloads
Refs: Persona profile nest · Onboarding
ID: TC-PREF-01
Given: User with email preference false
When: OL dispatch template cap.application.submitted channel email
Then: Skipped (no inbox/email). Same user auth.password_reset still delivers
Refs: Orchestrator notification preferences