Manual Test Plan — ABDM Multi-ABHA Patient Linkage
Date: 2026-07-10
Plan ref: docs/plans/2026-07-10-004-feat-abdm-multi-abha-patient-linkage-plan.md
Brainstorm ref: docs/brainstorms/2026-07-17-abdm-multi-abha-patient-linkage.md
Summary of Changes
This migration moves ABHA address/number storage from denormalized columns on
clinical.patients (abha_id, abha_number) and the old
integration.abdm_patient_profiles table to a new dedicated table
phr.abha_profiles (model: Phr::AbhaProfile). A patient can now have
multiple ABHA profiles across facilities.
Database Changes
| Change | Details |
|---|---|
| New table | phr.abha_profiles — globally unique abha_address + abha_number |
| Dropped table | integration.abdm_patient_profiles |
| Dropped columns | clinical.patients.abha_id, clinical.patients.abha_number |
| Renamed FK columns | integration.abdm_{consents,link_requests,care_contexts}.abdm_patient_profile_id → .abha_profile_id |
| Kept (temporary) | identity.patients.abha_address, identity.patients.abha_number (deferred to profile-switch picker pass) |
Model Changes
| Old | New |
|---|---|
Integration::AbdmPatientProfile | Phr::AbhaProfile |
Integration::AbdmPatientProfileBlueprint | Phr::AbhaProfileBlueprint |
Integration::AbdmPatientProfilePolicy | (removed — authorization inline) |
Clinical::Patient.abha_id (column) | Clinical::Patient.abha_address (helper → Phr::AbhaProfile) |
Clinical::Patient.abha_number (column) | Clinical::Patient.abha_number (helper → Phr::AbhaProfile) |
New Files
| File | Purpose |
|---|---|
db/migrate/20260710120001_rename_abdm_patient_profiles_to_abha_profiles.rb | Table migration |
db/migrate/20260710120002_rename_abdm_patient_profile_fks_to_abha_profile.rb | FK column renames |
domains/phr/models/abha_profile.rb | New model |
domains/phr/blueprints/abha_profile_blueprint.rb | New blueprint |
domains/clinical/services/patients/register_patient_service.rb | CMS create/update wrapper |
domains/integration/jobs/abdm/enqueue_link_token_job.rb | Link token generation job |
Deleted Files
| File | Replaced By |
|---|---|
domains/integration/models/abdm_patient_profile.rb | domains/phr/models/abha_profile.rb |
domains/integration/blueprints/abdm_patient_profile_blueprint.rb | domains/phr/blueprints/abha_profile_blueprint.rb |
domains/integration/policies/abdm_patient_profile_policy.rb | (removed) |
Pre-Test Setup
1. Run Migrations
cd backend
# WARNING: The down migration recreates integration.abdm_patient_profiles.
# Clean ABDM tables before testing if you need to rollback.
bin/rails db:migrateVerify the migration succeeded:
-- phr.abha_profiles exists
SELECT count(*) FROM phr.abha_profiles;
-- Old table is gone
SELECT count(*) FROM integration.abdm_patient_profiles;
-- Expected: ERROR: relation does not exist
-- Old columns are gone
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'clinical' AND table_name = 'patients'
AND column_name IN ('abha_id', 'abha_number');
-- Expected: 0 rows
-- FK columns renamed
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'integration'
AND table_name IN ('abdm_consents', 'abdm_link_requests', 'abdm_care_contexts')
AND column_name = 'abha_profile_id';
-- Expected: 3 rows2. Verify Duplicate ABHA Addresses
Before testing, check for existing duplicates that would violate the new global unique constraints:
SELECT abha_address, count(*) FROM phr.abha_profiles
WHERE abha_address IS NOT NULL
GROUP BY abha_address HAVING count(*) > 1;
-- Expected: 0 rows (or clean up before testing)3. Start Required Services
# Terminal 1
redis-server
# Terminal 2
postgres -D /usr/local/var/postgres
# Terminal 3
cd backend && bundle exec sidekiq
# Terminal 4
cd backend && rails s
# Terminal 5
cd frontend && pnpm dev:cmsTest Scenarios
Phase 1: Core Patient CRUD (CMS)
T1.1 — Create patient WITHOUT ABHA
Steps:
- Open CMS → Patients → Add Patient
- Fill in name, phone, gender, DOB (leave ABHA fields empty)
- Click Save
Expected:
- Patient created successfully
Phr::AbhaProfileis NOT created for this patientpatient.abha_addressreturnsnilpatient.abha_numberreturnsnil
Verify in DB:
SELECT * FROM phr.abha_profiles WHERE clinical_patient_id = '<patient_id>';
-- Expected: 0 rowsT1.2 — Create patient WITH ABHA address
Steps:
- Open CMS → Patients → Add Patient
- Fill name, phone, gender, DOB
- Enter ABHA address (e.g.,
testuser@sbx) - Click Save
Expected:
- Patient created successfully
Phr::AbhaProfileIS created with the ABHA addresspatient.abha_addressreturnstestuser@sbxEnqueueLinkTokenJobis enqueued (check Sidekiq logs)
Verify in DB:
SELECT * FROM phr.abha_profiles
WHERE abha_address = 'testuser@sbx';
-- Expected: 1 row with clinical_patient_id setT1.3 — Create patient WITH duplicate ABHA address (idempotency)
Steps:
- Create a second patient with the same ABHA address as T1.2
Expected:
- Patient created successfully
Phr::AbhaProfilecreation is skipped (unique constraint)- Warning logged:
[FindOrCreateService] skipped ABHA profile for patient ... EnqueueLinkTokenJobis NOT re-enqueued- Patient creation does NOT fail
T1.4 — Update existing patient to add ABHA
Steps:
- Edit the patient from T1.1 (no ABHA)
- Add ABHA address in the form
- Click Save
Expected:
Phr::AbhaProfilecreated for the existing patientpatient.abha_addressreturns the new ABHA addressEnqueueLinkTokenJobenqueued
T1.5 — Patient blueprint serialization
Steps:
GET /clinical/patients/:idfor a patient WITH an ABHA profile
Expected:
- Response JSON includes ABHA in the
identifiersarray:{ "system": "https://ndhm.gov.in", "code": "ABHA", "value": "testuser@sbx" } - Response JSON does NOT include
abha_idas a top-level field
Phase 2: ABDM HIP Linking Flow
T2.1 — HIP-initiated link (profile_share webhook)
Steps:
- Trigger a profile_share webhook with an ABHA address
- Check that the patient is found or created via
FindOrCreateService
Expected:
Phr::AbhaProfilecreated (or reused if already exists)Integration::AbdmLinkRequestcreated withabha_addresssetEnqueueLinkTokenJobenqueued for the profile + facility- Walk-in appointment created with
source_type='abdm'
T2.2 — Link token generation
Steps:
- Wait for
EnqueueLinkTokenJobto process (check Sidekiq) - Verify
LinkTokenServicewas called
Expected:
Integration::AbdmLinkRequesttransitions:initiated → auth_pending → token_generated- Link token stored on the link request
- Log:
[EnqueueLinkTokenJob] Token generated for profile ...
T2.3 — Care context linking
Steps:
- Finalize an encounter for a patient WITH an ABHA profile
EnqueueCareContextLinkJobfires
Expected:
CareContextLinkingServicereadspatient.abha_address(notpatient.abha_id)- Care contexts created and linked via ABDM
AbdmCareContextrecords haveabha_profile_idset
T2.4 — SMS notification after linking
Steps:
- Complete care context linking (T2.3)
auto_notify_smsfires
Expected:
SmsNotifyServicereadspatient.abha_address(notpatient.abha_id)- SMS sent to patient
Phase 3: ABDM HIU Consent Flow
T3.1 — Consent request creation
Steps:
- Create a consent request for a patient WITH ABHA
Expected:
ConsentRequestServicereadspatient.abha_addressfor the ABDM payloadIntegration::AbdmConsentcreated withabha_profile_id: nil(notabdm_patient_profile_id)- Request sent to ABDM
T3.2 — HIP consent webhook (incoming consent grant)
Steps:
- Trigger a hip consent webhook
Expected:
HipConsentHandlerfinds the patient viaPhr::AbhaProfile(notClinical::Patient.abha_id)Integration::AbdmConsentcreated withabha_profile_id: nil
Phase 4: PHR Login & Self-Registration
T4.1 — Self-registration with ABHA
Steps:
- Patient app: user self-registers at a facility with ABHA in settings
Expected:
SelfRegistrationService.propagate_abha_to_profilecreatesPhr::AbhaProfileEnqueueLinkTokenJobenqueued- Clinical patient gets ABHA identifiers in
identifiersJSONB
T4.2 — PHR login session sync
Steps:
- PHR login flow completes
sync_patient_profileinPhrSessionsControllerfires
Expected:
Phr::AbhaProfile.find_or_initialize_by(abha_address: ...)called- Profile updated with ABDM data
- No reference to
Integration::AbdmPatientProfile
Phase 5: UHI Booking Sync
T5.1 — UHI booking with phone + ABHA
Steps:
- Trigger a UHI booking that carries phone + ABHA address
Expected:
FindOrCreateServicecalled (notClinical::Patient.create!)Phr::AbhaProfilecreatedEnqueueLinkTokenJobenqueued
T5.2 — UHI guest patient (no phone)
Steps:
- Trigger a UHI booking WITHOUT phone but WITH ABHA address
Expected:
Clinical::Patient.create!called (no phone → can't use FindOrCreateService)Phr::AbhaProfilecreated separatelyEnqueueLinkTokenJobenqueued
T5.3 — UHI backfill ABHA on existing patient
Steps:
- UHI booking matches existing patient by phone
- Patient has no ABHA profile yet
- Booking carries an ABHA address
Expected:
Phr::AbhaProfilecreated for the existing patientEnqueueLinkTokenJobenqueued
Phase 6: Data Integrity Checks
T6.1 — Orphaned profiles
-- Profiles with no patient link at all
SELECT count(*) FROM phr.abha_profiles
WHERE identity_patient_id IS NULL AND clinical_patient_id IS NULL;
-- Expected: 0 (shouldn't happen in normal flows)T6.2 — FK integrity
-- Care contexts referencing non-existent profiles
SELECT count(*) FROM integration.abdm_care_contexts cc
LEFT JOIN phr.abha_profiles ap ON cc.abha_profile_id = ap.id
WHERE cc.abha_profile_id IS NOT NULL AND ap.id IS NULL;
-- Expected: 0
-- Consents referencing non-existent profiles
SELECT count(*) FROM integration.abdm_consents c
LEFT JOIN phr.abha_profiles ap ON c.abha_profile_id = ap.id
WHERE c.abha_profile_id IS NOT NULL AND ap.id IS NULL;
-- Expected: 0
-- Link requests referencing non-existent profiles
SELECT count(*) FROM integration.abdm_link_requests lr
LEFT JOIN phr.abha_profiles ap ON lr.abha_profile_id = ap.id
WHERE lr.abha_profile_id IS NOT NULL AND ap.id IS NULL;
-- Expected: 0T6.3 — Unique constraint enforcement
-- Try inserting a duplicate ABHA address
INSERT INTO phr.abha_profiles (tenant_id, abha_address, registration_status)
VALUES ('<tenant_id>', 'testuser@sbx', 'active');
-- Expected: ERROR: duplicate key value violates unique constraintPhase 7: FHIR Export & Prescription Data
T7.1 — FHIR patient export
Steps:
- Export a patient with ABHA as FHIR
Expected:
PatientExportServicereadspatient.abha_address(notpatient.abha_id)- FHIR
Patient.identifierincludes ABHA with systemhttps://healthid.abdm.gov.in
T7.2 — Prescription data builder
Steps:
- Build prescription data for a patient with ABHA
Expected:
PrescriptionDataBuilderoutputsabha_id: patient.abha_addressin the data hash
Phase 8: Discovery & Patient Matching
T8.1 — HIP discovery by ABHA
Steps:
- Trigger discovery with an ABHA address
Expected:
PatientMatcher.match_by_abhaqueriesPhr::AbhaProfile(notIntegration::AbdmPatientProfile)- Returns the clinical patient if a profile exists
T8.2 — HIP discovery link confirm
Steps:
- Complete discovery → link_confirm with ABHA address
Expected:
DiscoveryService.handle_link_confirmcreates/updatesPhr::AbhaProfile- Profile marked
phr_linked: true
Regression Checklist
| Area | Check | Pass/Fail |
|---|---|---|
| CMS patient create | Patient saves with all fields (MRN, address, emergency contact) | |
| CMS patient update | Patient updates without data loss | |
| CMS patient list | ABHA shows correctly in patient list | |
| CMS patient detail | ABHA address visible in patient profile | |
| ABHA verification | M1 verify flow works end-to-end | |
| HIP linking | M2 link flow works end-to-end | |
| Consent management | M3 consent request/grant works | |
| Care context linking | Encounters link to ABHA | |
| SMS notification | SMS sends after care context link | |
| UHI booking | Patient creation from UHI works | |
| PHR login | Session sync creates ABHA profile | |
| Self-registration | ABHA propagated to profile | |
| FHIR export | ABHA in FHIR identifiers | |
| Prescription data | ABHA in prescription data | |
| Patient matching | Discovery finds patient by ABHA | |
| Sidekiq jobs | EnqueueLinkTokenJob processes | |
| Blueprints | API responses include ABHA correctly |
Known Limitations & Deferred Items
-
Identity::Patient.abha_address/abha_numbercolumns remain — Writing to these fromgenerate_kulcare_session_service.rbcontinues. This creates a temporary dual-write window. Full cleanup deferred to the profile-switch picker pass. -
Profile switch picker — The patient app assumes a singular ABHA. Multi-profile switching UX is deferred pending auth research.
-
Specs not updated — The test specs still reference
Integration::AbdmPatientProfile. These need updating in a separate pass (not blocking for manual testing). -
Down migration — The down migration recreates
integration.abdm_patient_profilesbut cannot backfillidentity_patient_id(always NULL). Devs must clean ABDM tables before rolling back.
Rollback Procedure
If issues are found during testing:
cd backend
# Roll back both migrations
bin/rails db:rollback STEP=2
# Verify old table restored
# SELECT count(*) FROM integration.abdm_patient_profiles;Note: Rolling back requires the integration.abdm_patient_profiles table to
be recreated with the old schema. Data is copied back from phr.abha_profiles
but identity_patient_id will be NULL (old schema has no such column).