Kulcare Docs
Feature QA checklists

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:3000 with patient and clinical schemas migrated. CMS dev server on http://localhost:3001 (or shell on :3000 with CMS lazy-loaded). Patient app at patient/ 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               # test

Confirm 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/* requests

1.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.rb

Frontend typecheck (catches the most common breakage when refactoring hooks):

cd frontend
pnpm --filter=@kulcare/cms typecheck
pnpm --filter=@kulcare/patient typecheck

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

  • severity maps: mild→low, moderate→medium, severe→high
  • is_chronic is true when clinical_status IN (active, recurrence, relapse) AND onset > 90 days ago
  • linked_medications array is hydrated from linked_medication_ids (each entry has id, drug_name, is_active)
curl -H "Authorization: Bearer $CLINICIAN_TOKEN" \
  "$API/clinical/patients/$PATIENT_ID/lhr/vitals_trends"

Critical checks:

  • BP rows (LOINC 8480-6 systolic + 8462-4 diastolic at the same effective_datetime) merge into a single blood_pressure metric with data_points[].value = {systolic, diastolic}.
  • Heart rate, weight, temperature each show up as their own metric with scalar value.
  • normal_range populated for non-BP metrics; null for BP.
  • is_abnormal true when interpretation_code is set OR the value falls outside reference_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.

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_ids and linked_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).

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 requested row is written to identity.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

  1. Open any patient with same-tenant data → PatientDetailPage → click LHR tab.
  2. Expected: 4 summary cards (Health summary, Active medications, Lab reports, Health events) populate without "sample data" callouts.
  3. All 8 tabs (Timeline, Symptoms, Vitals, Problems, Medications, Correlations, Visits, Lab Results) load real data.
  4. Vitals tab renders trend charts (recharts line chart for BP, scalar metrics as separate cards).
  5. Out-of-reference-range vital points show as red pills.

5.2 Sheet embed inside an encounter

  1. Open an encounter → click "View patient history".
  2. The same LHR opens in a Sheet (right-side overlay) without leaving the encounter.
  3. All tabs work the same as in 5.1.
  4. Closing the Sheet returns to the encounter unchanged.
  1. Open Problems tab → click 3-dot menu on any active problem → "Link to medications".
  2. Dialog opens with the patient's full medication list. Already-linked medications are checked.
  3. Toggle selections, click "Save links".
  4. Expected: toast "Updated linked medications for [problem name]", dialog closes, ProblemCard re-renders with the new linked_medications chips. PATCH request hit /lhr/conditions/:id/link_medications.
  5. Pass an invalid medication id (manually via devtools) → 422 surfaces as an error toast.
  1. Open Medications tab → click 3-dot menu on any medication → "Link to problems".
  2. Dialog seeds with currently-linked problems (those whose linked_medication_ids already include this medication).
  3. Toggle selections, click "Save links".
  4. 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.
  5. Re-open the dialog — selections reflect server state.
  1. As Clinic B doctor on a patient who's been to Clinic A: banner shows "Request access" CTA.
  2. Click → banner switches to "Pending" state.
  3. Grant consent from the patient app → CMS banner refreshes (manual refetch or eventual push update) to "Active until [date]".
  4. Revoke from the patient app → CMS banner returns to "Request access" state.

5.6 Import-to-encounter

  1. Open an encounter at Clinic B. Open the History Sheet. Grant consent if needed.
  2. On any other-tenant row, click "Import to encounter".
  3. Verify: row is copied into Clinic B's clinical.* with source = 'clinical_imported' and source_reference = {origin_tenant_id, origin_record_id, fetched_at}.
  4. Re-clicking import is idempotent — same copy is returned.

6. Patient app flows

6.1 Health Records page

  1. Log into the patient app as a user who's been to two clinics with consent granted.
  2. Open Medical Records → 8 tabs render, mixing patient-reported + clinical-imported rows.
  3. Each row carries a source chip; clinic-authored rows show the clinic name.

6.2 Manage clinic access

  1. Settings → "Manage clinic access".
  2. List shows every tenant the patient has appeared at, with state per tenant: none / requested / granted (with expiry) / revoked / expired.
  3. From requested: click Grant or Deny.
  4. From granted: click Revoke. Confirm 2-step dialog warns "Already-imported records will remain at that clinic".
  5. From none: click "Pre-grant access" — creates a granted row even without a request having been made.

6.3 Fan-out truncation

  1. As a patient with appearances at >10 tenants, open Medical Records.
  2. Banner: "Showing data from 10 clinics — 3 truncated by request limit."
  3. 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 observations filtered by category=laboratory. Observations don't carry attachments — file PDFs/images live on Clinical::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. Surfacing diagnostic_reports through the LHR façade closes the gap.

6.4.1 ✅ Lab reports appear on the Lab Results tab

  1. Seed a Clinical::DiagnosticReport for the patient with category: :laboratory, status: :final, and a non-empty presented_form array:
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' }
  ]
)
  1. Hit the patient endpoint as the user:
curl -H "Authorization: Bearer $PATIENT_TOKEN" \
  "$API/patient/clinical/diagnostic_reports"
  1. Expected: one row per DiagnosticReport. Each row's data should include code_display, has_attachments: true, attachment_count, and a presented_form array carrying the URL + contentType.
  2. In the patient app's Lab Results tab, the row renders with the download CTA active (uses presented_form[].url for 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_form is empty/missing in the response → LongitudinalRecordService#resource_payload regressed the :diagnostic_reports enrichment branch.
  • 404 on GET /patient/clinical/diagnostic_reports → diagnostic_reports was removed from allowed_types in backend/domains/patient/routes.rb or from CLINICAL_RESOURCE_TYPES in LongitudinalRecordService.
  • 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::Patient rows in any tenant: same-tenant endpoints return {data: []} or {records: []}. Cross-tenant timeline returns empty timeline[] with facilities: []. 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 the Clinical::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 expired state.
  • Patient with two Clinical::Patient rows in the same tenant (rare data drift): the LHR view endpoints take a Clinical::Patient.id directly so this is unambiguous; the cross-tenant aggregator dedupes by tenant.
  • Concurrent link writes: two clinicians editing different conditions' linked_medication_ids simultaneously 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_trends returns 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_id

If 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.

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.

On this page