Kulcare Docs

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

ChangeDetails
New tablephr.abha_profiles — globally unique abha_address + abha_number
Dropped tableintegration.abdm_patient_profiles
Dropped columnsclinical.patients.abha_id, clinical.patients.abha_number
Renamed FK columnsintegration.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

OldNew
Integration::AbdmPatientProfilePhr::AbhaProfile
Integration::AbdmPatientProfileBlueprintPhr::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

FilePurpose
db/migrate/20260710120001_rename_abdm_patient_profiles_to_abha_profiles.rbTable migration
db/migrate/20260710120002_rename_abdm_patient_profile_fks_to_abha_profile.rbFK column renames
domains/phr/models/abha_profile.rbNew model
domains/phr/blueprints/abha_profile_blueprint.rbNew blueprint
domains/clinical/services/patients/register_patient_service.rbCMS create/update wrapper
domains/integration/jobs/abdm/enqueue_link_token_job.rbLink token generation job

Deleted Files

FileReplaced By
domains/integration/models/abdm_patient_profile.rbdomains/phr/models/abha_profile.rb
domains/integration/blueprints/abdm_patient_profile_blueprint.rbdomains/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:migrate

Verify 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 rows

2. 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:cms

Test Scenarios

Phase 1: Core Patient CRUD (CMS)

T1.1 — Create patient WITHOUT ABHA

Steps:

  1. Open CMS → Patients → Add Patient
  2. Fill in name, phone, gender, DOB (leave ABHA fields empty)
  3. Click Save

Expected:

  • Patient created successfully
  • Phr::AbhaProfile is NOT created for this patient
  • patient.abha_address returns nil
  • patient.abha_number returns nil

Verify in DB:

SELECT * FROM phr.abha_profiles WHERE clinical_patient_id = '<patient_id>';
-- Expected: 0 rows

T1.2 — Create patient WITH ABHA address

Steps:

  1. Open CMS → Patients → Add Patient
  2. Fill name, phone, gender, DOB
  3. Enter ABHA address (e.g., testuser@sbx)
  4. Click Save

Expected:

  • Patient created successfully
  • Phr::AbhaProfile IS created with the ABHA address
  • patient.abha_address returns testuser@sbx
  • EnqueueLinkTokenJob is enqueued (check Sidekiq logs)

Verify in DB:

SELECT * FROM phr.abha_profiles
  WHERE abha_address = 'testuser@sbx';
-- Expected: 1 row with clinical_patient_id set

T1.3 — Create patient WITH duplicate ABHA address (idempotency)

Steps:

  1. Create a second patient with the same ABHA address as T1.2

Expected:

  • Patient created successfully
  • Phr::AbhaProfile creation is skipped (unique constraint)
  • Warning logged: [FindOrCreateService] skipped ABHA profile for patient ...
  • EnqueueLinkTokenJob is NOT re-enqueued
  • Patient creation does NOT fail

T1.4 — Update existing patient to add ABHA

Steps:

  1. Edit the patient from T1.1 (no ABHA)
  2. Add ABHA address in the form
  3. Click Save

Expected:

  • Phr::AbhaProfile created for the existing patient
  • patient.abha_address returns the new ABHA address
  • EnqueueLinkTokenJob enqueued

T1.5 — Patient blueprint serialization

Steps:

  1. GET /clinical/patients/:id for a patient WITH an ABHA profile

Expected:

  • Response JSON includes ABHA in the identifiers array:
    { "system": "https://ndhm.gov.in", "code": "ABHA", "value": "testuser@sbx" }
  • Response JSON does NOT include abha_id as a top-level field

Phase 2: ABDM HIP Linking Flow

Steps:

  1. Trigger a profile_share webhook with an ABHA address
  2. Check that the patient is found or created via FindOrCreateService

Expected:

  • Phr::AbhaProfile created (or reused if already exists)
  • Integration::AbdmLinkRequest created with abha_address set
  • EnqueueLinkTokenJob enqueued for the profile + facility
  • Walk-in appointment created with source_type='abdm'

Steps:

  1. Wait for EnqueueLinkTokenJob to process (check Sidekiq)
  2. Verify LinkTokenService was called

Expected:

  • Integration::AbdmLinkRequest transitions: initiated → auth_pending → token_generated
  • Link token stored on the link request
  • Log: [EnqueueLinkTokenJob] Token generated for profile ...

T2.3 — Care context linking

Steps:

  1. Finalize an encounter for a patient WITH an ABHA profile
  2. EnqueueCareContextLinkJob fires

Expected:

  • CareContextLinkingService reads patient.abha_address (not patient.abha_id)
  • Care contexts created and linked via ABDM
  • AbdmCareContext records have abha_profile_id set

T2.4 — SMS notification after linking

Steps:

  1. Complete care context linking (T2.3)
  2. auto_notify_sms fires

Expected:

  • SmsNotifyService reads patient.abha_address (not patient.abha_id)
  • SMS sent to patient

Steps:

  1. Create a consent request for a patient WITH ABHA

Expected:

  • ConsentRequestService reads patient.abha_address for the ABDM payload
  • Integration::AbdmConsent created with abha_profile_id: nil (not abdm_patient_profile_id)
  • Request sent to ABDM

Steps:

  1. Trigger a hip consent webhook

Expected:

  • HipConsentHandler finds the patient via Phr::AbhaProfile (not Clinical::Patient.abha_id)
  • Integration::AbdmConsent created with abha_profile_id: nil

Phase 4: PHR Login & Self-Registration

T4.1 — Self-registration with ABHA

Steps:

  1. Patient app: user self-registers at a facility with ABHA in settings

Expected:

  • SelfRegistrationService.propagate_abha_to_profile creates Phr::AbhaProfile
  • EnqueueLinkTokenJob enqueued
  • Clinical patient gets ABHA identifiers in identifiers JSONB

T4.2 — PHR login session sync

Steps:

  1. PHR login flow completes
  2. sync_patient_profile in PhrSessionsController fires

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:

  1. Trigger a UHI booking that carries phone + ABHA address

Expected:

  • FindOrCreateService called (not Clinical::Patient.create!)
  • Phr::AbhaProfile created
  • EnqueueLinkTokenJob enqueued

T5.2 — UHI guest patient (no phone)

Steps:

  1. Trigger a UHI booking WITHOUT phone but WITH ABHA address

Expected:

  • Clinical::Patient.create! called (no phone → can't use FindOrCreateService)
  • Phr::AbhaProfile created separately
  • EnqueueLinkTokenJob enqueued

T5.3 — UHI backfill ABHA on existing patient

Steps:

  1. UHI booking matches existing patient by phone
  2. Patient has no ABHA profile yet
  3. Booking carries an ABHA address

Expected:

  • Phr::AbhaProfile created for the existing patient
  • EnqueueLinkTokenJob enqueued

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: 0

T6.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 constraint

Phase 7: FHIR Export & Prescription Data

T7.1 — FHIR patient export

Steps:

  1. Export a patient with ABHA as FHIR

Expected:

  • PatientExportService reads patient.abha_address (not patient.abha_id)
  • FHIR Patient.identifier includes ABHA with system https://healthid.abdm.gov.in

T7.2 — Prescription data builder

Steps:

  1. Build prescription data for a patient with ABHA

Expected:

  • PrescriptionDataBuilder outputs abha_id: patient.abha_address in the data hash

Phase 8: Discovery & Patient Matching

T8.1 — HIP discovery by ABHA

Steps:

  1. Trigger discovery with an ABHA address

Expected:

  • PatientMatcher.match_by_abha queries Phr::AbhaProfile (not Integration::AbdmPatientProfile)
  • Returns the clinical patient if a profile exists

Steps:

  1. Complete discovery → link_confirm with ABHA address

Expected:

  • DiscoveryService.handle_link_confirm creates/updates Phr::AbhaProfile
  • Profile marked phr_linked: true

Regression Checklist

AreaCheckPass/Fail
CMS patient createPatient saves with all fields (MRN, address, emergency contact)
CMS patient updatePatient updates without data loss
CMS patient listABHA shows correctly in patient list
CMS patient detailABHA address visible in patient profile
ABHA verificationM1 verify flow works end-to-end
HIP linkingM2 link flow works end-to-end
Consent managementM3 consent request/grant works
Care context linkingEncounters link to ABHA
SMS notificationSMS sends after care context link
UHI bookingPatient creation from UHI works
PHR loginSession sync creates ABHA profile
Self-registrationABHA propagated to profile
FHIR exportABHA in FHIR identifiers
Prescription dataABHA in prescription data
Patient matchingDiscovery finds patient by ABHA
Sidekiq jobsEnqueueLinkTokenJob processes
BlueprintsAPI responses include ABHA correctly

Known Limitations & Deferred Items

  1. Identity::Patient.abha_address/abha_number columns remain — Writing to these from generate_kulcare_session_service.rb continues. This creates a temporary dual-write window. Full cleanup deferred to the profile-switch picker pass.

  2. Profile switch picker — The patient app assumes a singular ABHA. Multi-profile switching UX is deferred pending auth research.

  3. Specs not updated — The test specs still reference Integration::AbdmPatientProfile. These need updating in a separate pass (not blocking for manual testing).

  4. Down migration — The down migration recreates integration.abdm_patient_profiles but cannot backfill identity_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).

On this page