M1 — Link Token Generation & ABHA-Address Flow: Test Cases
Scope: Every path that generates an ABDM HIP link token (care-context linking, M1→M2 bridge), and how the patient's ABHA address is selected / carried for each booking entry point (UHI, public appointment, CMS add/edit patient, scan & share).
Why this doc exists: link-token generation is fan-out across 7 call sites, and the ABHA address that "goes with" a booking is sourced differently in each flow. This doc maps every case so nothing silently drops the ABHA or fails to link.
Last verified: 2026-07-31 against
feature/abdm_integration_port.
1. Architecture — where link tokens are generated (7 entry points)
| # | Trigger | Service / Job | Sync or Async | File |
|---|---|---|---|---|
| L1 | CMS ABHA verification (verify_otp / select_account) | LinkTokenService.call via perform_auto_link_token | Sync | domains/integration/controllers/abdm/abha/verifications_controller.rb |
| L2 | Patient create (CMS add, patient-app order, public booking) | FindOrCreateService#enqueue_link_token_if_abha_linked → EnqueueLinkTokenJob | Async | domains/clinical/services/patients/find_or_create_service.rb:594 |
| L3 | Patient edit (CMS patients tab update) | RegisterPatientService#enqueue_link_token_job → EnqueueLinkTokenJob | Async | domains/clinical/services/patients/register_patient_service.rb:98 |
| L4 | Patient self-registration (public sign-up) | SelfRegistrationService → EnqueueLinkTokenJob | Async | domains/clinical/services/patients/self_registration_service.rb:145 |
| L5 | UHI appointment booking | UhiClinicalSyncService#enqueue_link_token → EnqueueLinkTokenJob | Async | domains/uhi/services/uhi_clinical_sync_service.rb:561 |
| L6 | HIP link webhook (profile share / token callbacks) | HipLinkHandler → EnqueueLinkTokenJob | Async | domains/integration/services/abdm/webhooks/hip_link_handler.rb:423 |
| L7 | Care-context linking (HI/M2 explicit) | CareContextLinkingService → LinkTokenService.call | Sync | domains/integration/services/abdm/hip/care_context_linking_service.rb:114 |
No double-enqueue. L2 (create) and L3 (update) live in mutually-exclusive branches of
RegisterPatientService#call(return update_patient if @patient_id.present?elsecreate_patient). A single request hits exactly one. Do not "dedupe" them — removing L3 would break ABHA linking on patient edits.
The async token-delivery model
LinkTokenService calls ABDM generate-token, which returns 202 Accepted (empty body).
The actual link token arrives seconds later via the webhook
POST /integration/abdm/webhooks/hip/on_token_generate → HipLinkHandler#on_token_generate
→ stored on integration.abdm_link_requests. Sidekiq must be running for the job, and
the webhook endpoint must be reachable, for tokens to land.
2. LinkTokenService guards (HIP / token state)
From domains/integration/services/abdm/hip/link_token_service.rb:
| Condition | Behavior |
|---|---|
Facility not registered as HIP (hip_id nil, line 42) | Returns failure: ['Facility is not registered as HIP']. Bypasses ABDM entirely. Non-blocking — job logs a warning and moves on. |
Valid, non-expired token already exists (link_request.token_valid?) | Fast-path reuse — no ABDM call. Returns status: 'token_already_exists'. |
| ABDM-1092 (duplicate) | Finds prior valid token for same facility + abha_address and reuses it. |
| No prior token | Generates request_id, calls ABDM, returns status: 'pending_webhook'. Token delivered by on_token_generate. |
Idempotency key: (patient + facility + abha_address) via find_or_create_link_request
(for_abha_address scope). Re-runs are safe.
3. How the ABHA address is selected per booking path
3a. UHI appointment booking ✅ logged-in ABHA flows
- FE:
patient/src/features/uhi/hooks/useUhiBooking.ts:152→abhaAddress = user?.abha_address ?? '' - Order payload:
customer.id = abhaAddress(line 268 / 399) - BE:
booking.abha_address = order.dig('customer', 'id')(hspa/confirm_controller.rb:63,eua/on_confirm_controller.rb:85) UhiClinicalSyncServiceline 62:enqueue_link_token(...) if booking.abha_address.present?- If patient has no profile yet,
backfill_abha_from_bookingcreates one frombooking.abha_address.
Source of user.abha_address: session.patient.abha_address returned by
GenerateKulcareSessionService (generate_kulcare_session_service.rb:280,
@abha_address.presence || patient.abha_address). Set on every ABHA login path.
⚠️ RISK — empty customer.id: if the patient is logged in via phone only (no ABHA),
user.abha_addressis empty → order sendscustomer.id: ''→booking.abha_addressnil → no link token fires. This is silent. See TC-LT-06.
3b. Public / direct appointment booking (patient app)
public_appointments/update_service.rb#find_or_create_patientbuildspatient_paramswith name, phone, email, gender, age, dob — NO abha field.- Calls
FindOrCreateService.enqueue_link_token_if_abha_linkedenqueues only ifpatient.phr_abha_profile_idis set. - FIXED:
backfill_abha_if_availablenow consults the identity patient's existingPhr::AbhaProfile(created at PHR login) — so a patient who logged in via ABHA and books directly gets their profile linked and the token fires, without the booking carrying the ABHA.appointment_orders/create_service.rbpassesidentity_user: @user, so the same backfill applies there.
A patient who has no ABHA at all (never logged in via ABHA) correctly gets no link — there is nothing to link. See TC-LT-04 / TC-LT-05.
3c. CMS patients tab — add patient (create)
patients_controller#create → RegisterPatientService (create branch) → FindOrCreateService
(L2). ABHA sourced from patient_params[:abha_id] / [:health_id]
(create_abha_for_new_patient, line 472) or from user.settings['abha'] (backfill). One
enqueue, via FindOrCreateService.
3d. CMS patients tab — edit patient (update)
patients_controller#update → RegisterPatientService (update branch, line 98) →
enqueue_link_token_job (L3). Does not call FindOrCreateService. ABHA from the edit
form (abha_address/abha_number params → create_abha_profile). One enqueue.
3e. Self-registration (public sign-up)
self_registration_service.rb:145 → EnqueueLinkTokenJob (L4).
4. Scan & Share (M1) — does a link token get generated?
Yes. Scan & Share is the patient walk-in flow: the patient scans the facility QR
in the PHR app (ScanSharePage → share({ token, hip_id, counter_id })) and shares
their profile. ABDM delivers it to the HIP:
- ABDM calls
POST /integration/abdm/webhooks/hip/profile_share - →
HipLinkHandler#profile_share(line 225) →find_or_create_shared_patient(passes the sharedabha_addressin patient_params viapatient_params_from_profile) - → FindOrCreateService creates/links the patient +
Phr::AbhaProfile - →
enqueue_link_token_for_patient(line 423, entry point L6) firesEnqueueLinkTokenJobfor the linked ABHA profile - →
register_walk_in_and_tokenissues a visit / queue token - → acknowledges via
/on-share(SUCCESS or FAILED) → Ably delivers the token to the PHR app
So Scan & Share produces both a visit/queue token (for the encounter) and a care-context link request (
integration.abdm_link_requestsrow via L6 →on_token_generatewebhook). The two are distinct: the visit token is the queue number shown to the patient; the link token is what lets the HIP later add care contexts to this ABHA (M2).Failure modes: if the shared profile lacks
phone_number/country_code, patient registration fails →/on-shareFAILED → no link token (patient wasn't created).
5. Test-case matrix
Legend: ✅ expected pass · ⚠️ known gap/risk · ❌ negative test
Link-token generation paths
| ID | Scenario | Expected | Ref |
|---|---|---|---|
| TC-LT-01 | CMS ABHA verify (abha_number), HIP active | Sync link request created (L1); on_token_generate webhook stores token ~2-3s later | §1 L1 |
| TC-LT-02 | CMS ABHA verify, then book appointment | Appointment's EnqueueLinkTokenJob (L2) hits idempotency fast-path (token_already_exists) | §1 L2 |
| TC-LT-03 | CMS add patient with abha_address, HIP active | L2 fires once; abdm_link_requests row created | §3c |
| TC-LT-03b | CMS edit patient, add abha_address | L3 fires once (NOT L2); abdm_link_requests row created | §3d |
| TC-LT-04 | Patient-app direct booking, user has prior phr_abha_profile (logged in via ABHA before) | L2 fires (backfill links the login-created profile). FIXED — see §3b | §3b |
| TC-LT-05 | ✅ Patient-app direct booking, user logged in via phone only | If the user has any ABHA from a prior login, backfill now links it (login-created phr.abha_profiles row). If the user genuinely has no ABHA, correctly no link. FIXED | §3b |
| TC-LT-06 | ⚠️❌ UHI booking, patient logged in via phone only (user.abha_address empty) | customer.id: '' → booking.abha_address nil → no link token | §3a RISK |
| TC-LT-07 | ✅ UHI booking, patient logged in via ABHA | customer.id = logged-in ABHA → booking.abha_address set → L5 fires | §3a |
| TC-LT-08 | Multi-ABHA patient, UHI booking | Only the logged-in user.abha_address flows (single value); other ABHAs not linked by this booking | §3a |
| TC-LT-09 | Self-registration with ABHA | L4 fires once | §3e |
Guard behavior
| ID | Scenario | Expected | Ref |
|---|---|---|---|
| TC-LT-10 | ❌ Facility not registered as HIP + ABHA verification | LinkTokenService returns failure 'Facility is not registered as HIP'; no ABDM call; non-blocking (verification still succeeds for the user) | §2 |
| TC-LT-11 | HIP active, valid token already exists (re-book / re-verify) | Fast-path reuse, status: token_already_exists; no ABDM generate call | §2 |
| TC-LT-12 | HIP active, prior token expired | New generate-token → 202 → webhook delivers fresh token | §2 |
| TC-LT-13 | ABDM-1092 (duplicate) on generate | Reuses prior valid token for same facility + abha_address | §2 |
Scan & share
| ID | Scenario | Expected | Ref |
|---|---|---|---|
| TC-LT-14 | M1 scan & share at HIP counter | profile_share webhook → patient + ABHA profile created + visit token issued + link token enqueued (L6); /on-share SUCCESS. Expect both a queue token AND an abdm_link_requests row | §4 |
| TC-LT-15 | Scan & share where shared profile missing phone/country_code | Patient registration fails → /on-share FAILED; no link token (no patient created) | §4 validate_profile_share |
6. DB inspection queries
-- All link requests for a patient's ABHA address (most useful single query)
SELECT id, patient_id, abha_address, abha_number, status, link_token IS NOT NULL AS has_token,
valid_to, created_at, updated_at
FROM integration.abdm_link_requests
WHERE abha_address = 'simmi@sbx'
ORDER BY created_at DESC;
-- Is the facility a HIP?
SELECT id, facility_id, service_id, active
FROM integration.abdm_facility_registrations
WHERE facility_id = '<facility-uuid>' AND registration_type = 'HIP';
-- Does the patient have an ABHA profile? (the gate for L2)
SELECT p.id, p.phr_abha_profile_id, ap.abha_address, ap.abha_number
FROM clinical.patients p
LEFT JOIN phr.abha_profiles ap ON ap.id = p.phr_abha_profile_id
WHERE p.id = '<clinical-patient-uuid>';
-- Recent ABDM API logs (generate-token + webhook) for an abha_address
SELECT id, endpoint, method, status_code, created_at
FROM integration.abdm_api_logs
WHERE (request_payload ->> 'abha_address' = 'simmi@sbx'
OR response_payload ->> 'abha_address' = 'simmi@sbx')
ORDER BY created_at DESC LIMIT 20;7. Known gaps / risks (open items)
-
RESOLVED (TC-LT-04/05) — backfill now consults
phr.abha_profiles.FindOrCreateService#backfill_abha_if_availablewas changed to look up the identity patient's existingPhr::AbhaProfilerow (created at PHR login) as the primary source — independent of whether the FE wroteuser.settings['abha']. The create path now calls backfill too. So a patient who logged in via ABHA and later books a direct/public/appointment-order gets linked and the link token fires. The only remaining "no link" case is a patient who genuinely has no ABHA at all (correct behavior). -
UHI
customer.idstill depends onuser.abha_address(TC-LT-06). If the patient is logged in via phone only, the UHI order sendscustomer.id: ''→ no ABHA → no link. This is expected for phone-only users (no ABHA to send); acceptable default. -
Multi-ABHA UHI selection. UHI carries only the single
user.abha_address. No UI to pick which ABHA a UHI booking should link. Acceptable default, but documented. -
RESOLVED —
test_fails_if_phone_number_missingdrift. Phone is optional by design (UHI guest patients); the test was asserting obsolete "phone required" behavior. Replaced with a test asserting phone is optional (phone_number is optional (UHI guest patients arrive without it)). Full suite green.
8. Quick verification greps (confirm wiring before each pass)
# All EnqueueLinkTokenJob call sites (expect L2-L6)
grep -rn "EnqueueLinkTokenJob.perform_later" backend/domains backend/parts --include="*.rb"
# Sync LinkTokenService.call sites (expect L1, L7)
grep -rn "LinkTokenService.call" backend/domains --include="*.rb"
# UHI customer.id source
grep -n "user?.abha_address" patient/src/features/uhi/hooks/useUhiBooking.ts
# The HIP guard
grep -n "unless hip_id" backend/domains/integration/services/abdm/hip/link_token_service.rb