LHR QA Guide
Scope: End-to-end test plan for the Longitudinal Health Record across CMS, the patient app, and the backend that feeds both.
Prerequisites: Dev backend on
http://localhost:3000withpatientandclinicalschemas migrated. CMS dev server onhttp://localhost:3001(or shell on:3000with CMS lazy-loaded). Patient app atpatient/running on its own dev port.Related: CMS developer guide · Product overview · Cross-tenant QA · Patient-domain QA
1. Test environment setup
1.1 Database
cd backend
bundle exec rails db:migrate # dev
RAILS_ENV=test bundle exec rails db:migrate # testConfirm the LHR-specific bits are in place:
psql kulcare_development -c "\d clinical.conditions" | grep linked_medication_ids
# Expect: linked_medication_ids | uuid[]If the column is missing, the May 2026 migration didn't run — re-run db:migrate.
1.2 Auth
Two tokens are needed:
- Clinician JWT (tenant + practitioner) for CMS endpoints
- Patient JWT (user-scoped, no tenant) for patient-app endpoints
export API="http://localhost:3000" # local; use staging URL on staging
export CLINICIAN_TOKEN="eyJhbGc..." # for /clinical/* requests
export PATIENT_TOKEN="eyJhbGc..." # for /patient/* requests1.3 Fixtures
For cross-tenant tests, seed a patient who has appeared at two tenants:
bundle exec rails runner "
ip = Identity::Patient.find_or_create_by!(phone: '+919999900001') { |p| p.first_name = 'LHR'; p.last_name = 'TestPatient' }
Clinical::Patients::FindOrCreateService.call(identity_patient: ip, tenant: Identity::Tenant.find_by(slug: 'tenant-a'))
Clinical::Patients::FindOrCreateService.call(identity_patient: ip, tenant: Identity::Tenant.find_by(slug: 'tenant-b'))
"Add at least one prescription, one condition, and a vital observation under each tenant so the cross-tenant timeline has something to merge.
2. Automated specs
The backend contract is covered by request specs. Run before any manual QA:
cd backend
bundle exec rspec domains/clinical/spec/requests/lhr_views_spec.rb
bundle exec rspec domains/clinical/spec/requests/longitudinal_records_spec.rb
bundle exec rspec domains/identity/spec/requests/cross_tenant_consents_spec.rbFrontend typecheck (catches the most common breakage when refactoring hooks):
cd frontend
pnpm --filter=@kulcare/cms typecheck
pnpm --filter=@kulcare/patient typecheckPre-existing CMS errors are unrelated; the LHR feature directory must not add new ones.
3. Same-tenant LHR views (CMS)
For each endpoint below, the response must match the patient-app row shape — that's the contract the ported views depend on. Use the clinician JWT and substitute a real :patient_id.
3.1 Medications
curl -H "Authorization: Bearer $CLINICIAN_TOKEN" \
"$API/clinical/patients/$PATIENT_ID/lhr/medications"Expect {data: [{id, drug_name, dosage, is_active, adherence_log: null, ...}]}. adherence_log is intentionally null — UI shows "Adherence tracking — coming soon".
3.2 Conditions
curl -H "Authorization: Bearer $CLINICIAN_TOKEN" \
"$API/clinical/patients/$PATIENT_ID/lhr/conditions"Spot-checks:
severitymaps:mild→low,moderate→medium,severe→highis_chronicis true whenclinical_status IN (active, recurrence, relapse)ANDonset > 90 days agolinked_medicationsarray is hydrated fromlinked_medication_ids(each entry hasid,drug_name,is_active)
3.3 Vitals trends
curl -H "Authorization: Bearer $CLINICIAN_TOKEN" \
"$API/clinical/patients/$PATIENT_ID/lhr/vitals_trends"Critical checks:
- BP rows (LOINC
8480-6systolic +8462-4diastolic at the sameeffective_datetime) merge into a singleblood_pressuremetric withdata_points[].value = {systolic, diastolic}. - Heart rate, weight, temperature each show up as their own metric with scalar
value. normal_rangepopulated for non-BP metrics; null for BP.is_abnormaltrue when interpretation_code is set OR the value falls outsidereference_range.
3.4 Symptoms
curl -H "Authorization: Bearer $CLINICIAN_TOKEN" \
"$API/clinical/patients/$PATIENT_ID/lhr/symptoms"The transformer pulls Observation rows with category IN (clinical_history, exam, survey, social_history) — there is no symptom literal in the enum. If you're seeding fixtures, use one of those four categories.
3.5 Encounters
curl -H "Authorization: Bearer $CLINICIAN_TOKEN" \
"$API/clinical/patients/$PATIENT_ID/lhr/encounters?page=1&limit=20"Returns {data, meta: {total, page, limit, pages}}. Practitioner display_name should be set on each row.
3.6 Lab records
curl -H "Authorization: Bearer $CLINICIAN_TOKEN" \
"$API/clinical/patients/$PATIENT_ID/lhr/lab_records"Returns {records: [...]} (note: not {data: [...]}). Filters clinical.documents by document_type IN (lab_report, laboratory_report, lab, pathology). If a known lab doc isn't appearing, check the document_type value against LAB_DOCUMENT_TYPES in the transformer.
3.7 Link medications PATCH
curl -X PATCH -H "Authorization: Bearer $CLINICIAN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"medication_ids": ["<med-uuid-1>", "<med-uuid-2>"]}' \
"$API/clinical/patients/$PATIENT_ID/lhr/conditions/$CONDITION_ID/link_medications"Verify:
- Response 200 with the condition's full transformed shape including the new
linked_medication_idsandlinked_medications. - Passing an unknown medication id returns 422 with
{error: 'invalid_medication_ids', invalid: [...]}. - Passing a condition that belongs to a different patient returns 403.
- Replacing the full set works (passing an empty array clears links).
4. Cross-tenant timeline + consent
4.1 Same-tenant baseline (no consent needed)
As Clinic A's doctor, request the patient's longitudinal record. Same-tenant rows must always appear, regardless of consent state.
curl -H "Authorization: Bearer $CLINICIAN_TOKEN_TENANT_A" \
"$API/clinical/patients/$PATIENT_ID/longitudinal_record?limit=50"Expect timeline[] to contain Clinic A's rows. meta.consent either null (no other tenants) or an object describing the pending/missing consent for other tenants.
4.2 Request access from Clinic B
As Clinic B's doctor, open the same patient. Same-tenant rows visible. meta.consent.state should be none or expired. Click "Request access" in the banner:
curl -X POST -H "Authorization: Bearer $CLINICIAN_TOKEN_TENANT_B" \
-H "Content-Type: application/json" \
-d '{}' \
"$API/clinical/patients/$PATIENT_ID/request_lhr_consent"Verify:
- A
requestedrow is written toidentity.cross_tenant_consents. - The patient gets a notification (SMS + push) — check the Ably channel and provider logs.
- Re-issuing the request is idempotent (returns the same row, no duplicate).
4.3 Patient grants
In the patient app's "Manage clinic access" page (or via API):
curl -X POST -H "Authorization: Bearer $PATIENT_TOKEN" \
"$API/identity/cross_tenant_consents/$CONSENT_ID/grant"State transitions to granted, expires_at is set to granted_at + 30 days.
4.4 Verify other-tenant rows now appear
Re-fetch the longitudinal record as Clinic B's doctor. Tenant A's rows should now be merged into the timeline. meta.consent.state is granted.
4.5 Patient revokes
curl -X POST -H "Authorization: Bearer $PATIENT_TOKEN" \
"$API/identity/cross_tenant_consents/$CONSENT_ID/revoke"Re-fetch as Clinic B's doctor. Other-tenant rows disappear from future reads. Any rows that were import-to-encounter copied remain (they're in Clinic B's clinical.* now with clinical_imported provenance).
4.6 Truncation banner
If a patient has appeared at more than Clinical::Patients::LongitudinalRecordService::MAX_TENANTS_PER_REQUEST (10) tenants, meta.fan_out.truncated is true. Confirm the CMS UI surfaces a "Showing data from X clinics, Y truncated" banner.
5. CMS UI flows
5.1 LHR tab on patient detail
- Open any patient with same-tenant data →
PatientDetailPage→ click LHR tab. - Expected: 4 summary cards (Health summary, Active medications, Lab reports, Health events) populate without "sample data" callouts.
- All 8 tabs (Timeline, Symptoms, Vitals, Problems, Medications, Correlations, Visits, Lab Results) load real data.
- Vitals tab renders trend charts (recharts line chart for BP, scalar metrics as separate cards).
- Out-of-reference-range vital points show as red pills.
5.2 Sheet embed inside an encounter
- Open an encounter → click "View patient history".
- The same LHR opens in a Sheet (right-side overlay) without leaving the encounter.
- All tabs work the same as in 5.1.
- Closing the Sheet returns to the encounter unchanged.
5.3 Link medications dialog (Problems tab)
- Open Problems tab → click 3-dot menu on any active problem → "Link to medications".
- Dialog opens with the patient's full medication list. Already-linked medications are checked.
- Toggle selections, click "Save links".
- Expected: toast "Updated linked medications for [problem name]", dialog closes, ProblemCard re-renders with the new
linked_medicationschips. PATCH request hit/lhr/conditions/:id/link_medications. - Pass an invalid medication id (manually via devtools) → 422 surfaces as an error toast.
5.4 Link problems dialog (Medications tab)
- Open Medications tab → click 3-dot menu on any medication → "Link to problems".
- Dialog seeds with currently-linked problems (those whose
linked_medication_idsalready include this medication). - Toggle selections, click "Save links".
- Expected: one PATCH fires per affected condition (added or removed). Watch the network tab — N PATCHes for N changed conditions, zero for unchanged. Single toast on completion.
- Re-open the dialog — selections reflect server state.
5.5 Cross-tenant consent banner
- As Clinic B doctor on a patient who's been to Clinic A: banner shows "Request access" CTA.
- Click → banner switches to "Pending" state.
- Grant consent from the patient app → CMS banner refreshes (manual refetch or eventual push update) to "Active until [date]".
- Revoke from the patient app → CMS banner returns to "Request access" state.
5.6 Import-to-encounter
- Open an encounter at Clinic B. Open the History Sheet. Grant consent if needed.
- On any other-tenant row, click "Import to encounter".
- Verify: row is copied into Clinic B's
clinical.*withsource = 'clinical_imported'andsource_reference = {origin_tenant_id, origin_record_id, fetched_at}. - Re-clicking import is idempotent — same copy is returned.
6. Patient app flows
6.1 Health Records page
- Log into the patient app as a user who's been to two clinics with consent granted.
- Open Medical Records → 8 tabs render, mixing patient-reported + clinical-imported rows.
- Each row carries a source chip; clinic-authored rows show the clinic name.
6.2 Manage clinic access
- Settings → "Manage clinic access".
- List shows every tenant the patient has appeared at, with state per tenant:
none/requested/granted(with expiry) /revoked/expired. - From
requested: click Grant or Deny. - From
granted: click Revoke. Confirm 2-step dialog warns "Already-imported records will remain at that clinic". - From
none: click "Pre-grant access" — creates agrantedrow even without a request having been made.
6.3 Fan-out truncation
- As a patient with appearances at >10 tenants, open Medical Records.
- Banner: "Showing data from 10 clinics — 3 truncated by request limit."
- Open Manage clinic access — all tenants listed regardless of fan-out cap.
6.4 Lab Results tab — DiagnosticReport with downloadable attachments (PR #1632)
Why this exists: Pre-PR #1632 the Lab Results tab queried
observationsfiltered bycategory=laboratory. Observations don't carry attachments — file PDFs/images live onClinical::DiagnosticReport.presented_form(FHIR R4 shape). Uday's 2026-05-19 QA report: "PDF does not open" — the tab had rows but no file URLs to render download CTAs from. Surfacingdiagnostic_reportsthrough the LHR façade closes the gap.
6.4.1 ✅ Lab reports appear on the Lab Results tab
- Seed a
Clinical::DiagnosticReportfor the patient withcategory: :laboratory,status: :final, and a non-emptypresented_formarray:
Clinical::DiagnosticReport.create!(
tenant: tenant,
patient: clinical_patient,
status: :final,
category: :laboratory,
code: { 'system' => 'loinc', 'code' => '58410-2', 'display' => 'CBC panel' },
issued: 1.day.ago,
presented_form: [
{ 'url' => 'https://s3.example.com/cbc.pdf',
'contentType' => 'application/pdf',
'title' => 'CBC report' }
]
)- Hit the patient endpoint as the user:
curl -H "Authorization: Bearer $PATIENT_TOKEN" \
"$API/patient/clinical/diagnostic_reports"- Expected: one row per DiagnosticReport. Each row's
datashould includecode_display,has_attachments: true,attachment_count, and apresented_formarray carrying the URL + contentType. - In the patient app's Lab Results tab, the row renders with the download CTA active (uses
presented_form[].urlfor the link).
6.4.2 ✅ Cross-tenant aggregation works on the new resource
Same setup as 6.4.1 but with reports in two tenants where the patient has Clinical::Patient rows (one with consent granted). Open the patient app's Lab Results tab.
Expected: reports from both tenants surface; the consent-gated tenant's rows appear when consent is granted and disappear when revoked. Tenant chip on each row identifies the clinic.
6.4.3 ✅ Sort order is descending by issued
Seed two reports with different issued timestamps (e.g. 1 day ago and 7 days ago). The recent one should appear first in the list. The regression spec clinical_records_spec.rb covers this — manual smoke just confirms the UI honors the API order.
6.4.4 ✅ Pagination metadata still renders
Same as the other LHR resource tabs — meta.total, meta.page, meta.limit, meta.fan_out all present in the response envelope. UI's pagination control reads meta.pages.
6.4.5 ❌ Fail modes
- Row appears but
presented_formis empty/missing in the response →LongitudinalRecordService#resource_payloadregressed the:diagnostic_reportsenrichment branch. - 404 on
GET /patient/clinical/diagnostic_reports→diagnostic_reportswas removed fromallowed_typesinbackend/domains/patient/routes.rbor fromCLINICAL_RESOURCE_TYPESinLongitudinalRecordService. - File URLs are present but click does nothing → frontend issue in the patient app's Lab Results component; backend is correct.
7. Edge cases
- Patient with no
Clinical::Patientrows in any tenant: same-tenant endpoints return{data: []}or{records: []}. Cross-tenant timeline returns emptytimeline[]withfacilities: []. CMS empty-state copy renders. - Patient with
user_id IS NULL(legacy patient never claimed by a real user): same-tenant LHR view endpoints still work (they take theClinical::Patient.id, not the user id). Cross-tenant timeline requires user_id and returns an explanatory error; the CMS banner surfaces it. - Consent expires mid-session: timeline re-fetch should drop other-tenant rows on the next query refresh. CMS banner moves to
expiredstate. - Patient with two
Clinical::Patientrows in the same tenant (rare data drift): the LHR view endpoints take aClinical::Patient.iddirectly so this is unambiguous; the cross-tenant aggregator dedupes by tenant. - Concurrent link writes: two clinicians editing different conditions'
linked_medication_idssimultaneously must not stomp each other. Spec covers a single PATCH writing the full set per condition.
8. Performance smoke
A patient with ~200 observations across vitals categories should:
GET /lhr/vitals_trendsreturns in under 300ms locally.- Chart renders without jank — pagination via "Show more" is not yet implemented; if a patient genuinely has thousands of rows, raise as a follow-up.
GET /clinical/patients/:id/longitudinal_record?limit=50 should return in under 1s for a patient at 3 tenants.
9. Bug repro recipes
"Active medications" count shows 0 even though active medications exist
Most often the patient has user_id IS NULL on their Clinical::Patient row. Confirm in console:
Clinical::Patient.find(<id>).user_idIf null, the cross-tenant aggregator can't find them via user; same-tenant LHR view endpoints still work. Fix by claiming the patient (link an Identity::User) or use the LHR view endpoints which key off clinical_patient.id directly.
Blood pressure not showing on Vitals chart
Systolic and diastolic must share an exact effective_datetime to merge. Mismatched timestamps emit two unmerged points that fail to render. Check the seeding code — observations must be created with the same timestamp value, not Time.now calls back-to-back.
Symptom not appearing on Symptoms tab
category must be one of clinical_history, exam, survey, social_history. If a fixture uses :symptom or anything else, the transformer filters it out. The enum check in clinical.observations will also reject literal 'symptom' at the DB level.
Link mutation 422 with "invalid_medication_ids"
The controller validates that all medication ids exist within the current tenant. Cross-tenant medication ids fail validation. Use the same-tenant medications endpoint to confirm the ids being passed.