Kulcare Docs
Feature QA checklists

Patient cross-tenant appointments — QA cases

Test cases for the three workstreams shipped May 2026. Companion to the developer guide and the Patient::Domain QA doc.

Scope: covers GET /patient/appointments (Workstream A), GET /clinical/patients/federated_search + auto-provision at booking (Workstream B), and POST /patient/appointment_orders (Workstream C).


1. Test environment setup

1.1 Required fixtures

-- Three tenants (representing three independent clinics)
SELECT id, name FROM identity.tenants WHERE slug IN ('clinic-a', 'clinic-b', 'clinic-c');

-- A patient user with Clinical::Patient rows in clinics A and B (NOT C)
SELECT u.id AS user_id, u.email, u.phone_country_code, u.phone_number,
       cp.tenant_id, cp.id AS clinical_patient_id
FROM identity.users u
LEFT JOIN clinical.patients cp ON cp.user_id = u.id
WHERE u.email = 'patient-cross-tenant@example.com';

-- One vendor with an active appointment for this user (no tenant)
SELECT a.id, a.start_time, a.appointable_type, a.attendee_user_id, a.tenant_id
FROM clinical.appointments a
WHERE a.attendee_user_id = '<user_id>' AND a.appointable_type = 'Identity::Vendor';

-- A PHONELESS dependent managed by the guardian (#1864). phone_number must be
-- NULL, not an 'm:<hex>' placeholder — the placeholder is what #1864 removed.
SELECT ip.id AS dependent_identity_patient_id, ip.user_id AS dependent_user_id,
       ip.phone_number, ip.managed_by_user_id
FROM identity.patients ip
WHERE ip.managed_by_user_id = '<guardian_user_id>' AND ip.phone_number IS NULL;

-- Nothing anywhere should still carry a placeholder:
SELECT count(*) FROM identity.patients WHERE phone_number LIKE 'm:%';  -- expect 0

1.2 Auth tokens

TokenUse for
Patient JWT (Identity::User session)/patient/appointments, /patient/appointment_orders
Patient JWT + X-On-Behalf-Of: <dependent_id> headerGuardian-on-behalf-of flow
Clinic-A staff JWT (via tenant + facility)/clinical/patients/federated_search from clinic A

2. Workstream A — GET /patient/appointments

2.1 Cross-tenant aggregation

Setup: patient has appointments at clinics A and B (one each) plus one vendor appointment.

Steps:

  1. GET /patient/appointments with patient JWT.

Expected:

  • 200 OK
  • data.length == 3
  • meta.fan_out == 2 (two tenants iterated)
  • One row per appointment, sorted by appointment_time DESC
  • Practitioner appointments have clinic_details.location.address_line_1 populated
  • Vendor appointment has doctor_details.kind == 'vendor' and clinic_details == null and tenant_id == null

2.2 N+1 absence

Steps:

  1. Tail Rails log while running test 2.1.

Expected:

  • Number of SQL queries scales with meta.fan_out, not with data.length. Per-tenant: one query for appointments, one for practitioner includes, one for facility+locations. Vendor: one global query.

2.3 Vendor appointment without practitioner

Setup: patient has only a vendor appointment, no practitioner appointments.

Expected:

  • 200 OK with one row
  • doctor_details.kind == 'vendor'
  • chief_complaint is null (vendor appointments have no reason_codes typically)

2.4 Filtering

Steps:

  1. GET /patient/appointments?status=cancelled
  2. GET /patient/appointments?from=2026-01-01&to=2026-12-31

Expected:

  • Status filter narrows correctly
  • Range filter works on start_time
  • Combined with vendor union — vendor query also respects filters

2.5 LHR endpoint removal

Steps:

  1. GET /patient/clinical/appointments with patient JWT.

Expected:

  • 404 Not Found (:appointments removed from Clinical::Patients::LongitudinalRecordService::CLINICAL_RESOURCE_TYPES)

2.6 Subject vs current user (guardian on-behalf-of)

Setup: Guardian user has dependent. Dependent has appointments at clinic B.

Steps:

  1. GET /patient/appointments with guardian JWT + X-On-Behalf-Of: <dependent_id> header.

Expected:

  • Returns dependent's appointments, NOT guardian's.
  • Pre-fix bug: this would return guardian's appointments because current_user was used instead of subject_user.

3.1 Three-bucket return

Setup: Patient X has Clinical::Patient at clinic A. Patient Y is registered via patient app (Identity::User exists, no Clinical::Patient anywhere). Patient Z phone has no match.

Steps:

  1. From clinic A: GET /clinical/patients/federated_search?phone=<X.phone>&country_code=91
  2. From clinic A: GET /clinical/patients/federated_search?phone=<Y.phone>&country_code=91
  3. From clinic A: GET /clinical/patients/federated_search?phone=<Z.phone>&country_code=91

Expected:

Stepin_clinicin_kulcareno_match
11 (X's clinic A row)0[]
201 (Y's identity, registered_via: 'patient_app')[]
300[]

3.2 Privacy guard — no other-clinic leakage

Setup: Patient X has Clinical::Patient at clinics B and C. Phone 9876543210.

Steps:

  1. From clinic A: GET /clinical/patients/federated_search?phone=9876543210&country_code=91

Expected:

  • Response includes in_kulcare row for X
  • Response JSON does NOT contain clinic B's id, clinic B's name, clinic C's id, or clinic C's name
  • registered_via is 'another_clinic' (no Identity::User) or 'patient_app' (if X has one)

Verification:

# Run the search and grep the response for clinic-B/C identifiers
curl -H "Authorization: Bearer <clinic_a_jwt>" \
     "<api>/clinical/patients/federated_search?phone=9876543210&country_code=91" \
| jq . | grep -E '<clinic_b_id>|<clinic_b_name>|<clinic_c_id>|<clinic_c_name>'
# Expect: 0 matches

3.3 Phone country_code required

Steps:

  1. GET /clinical/patients/federated_search?phone=9876543210 (no country_code)

Expected:

  • 422 with body containing phone search requires country_code
  • Response time fast — early validation, no DB hit.

3.4 LIKE wildcard sanitization

Steps:

  1. GET /clinical/patients/federated_search?name=%
  2. GET /clinical/patients/federated_search?name=ab (2 chars, below minimum)
  3. GET /clinical/patients/federated_search?name=%25%5F%25 (literal %_%)

Expected:

  1. Either matches a literal % in name (escaped) or returns no rows. Does NOT enumerate the entire identity table.
  2. 422 with name must be at least 3 characters.
  3. Treats %_% as literal characters, not wildcards.

Verification: the underlying query uses ActiveRecord::Base.sanitize_sql_like(@name.downcase) per Clinical::Patients::FederatedSearchService#safe_like_term.

3.5 Auto-provision via identity_patient_id

Setup: Identity::Patient X exists. No Clinical::Patient for X in clinic A.

Steps:

  1. POST /clinical/appointment_orders with identity_patient_id: <X.id>, practitioner_id, facility_id, time slot.

Expected:

  • 201 Created
  • Exactly one new row in clinical.patients for tenant A linked to identity_patient: <X.id>
  • Audit::PhiAccessLog row with reason_detail.via == 'federated_search_auto_provision' and pre_existing_in_other_tenants > 0 (if X had Clinical::Patient elsewhere)
  • The pre_existing_in_other_tenants value never appears in the booking response JSON

3.6 Auto-provision race-safety

Setup: Identity::Patient X exists. No Clinical::Patient for X in clinic A.

Steps:

  1. Submit two POST /clinical/appointment_orders for X concurrently from clinic A (use xargs -P 2 or similar).

Expected:

  • Exactly one new Clinical::Patient row created in clinic A
  • The unique partial index idx_cl_patients_tenant_identity_unique enforces this
  • One booking succeeds, the other either succeeds (re-read found existing) or fails cleanly with a known error

3.7 Audit B1 — in_kulcare exposure

Steps:

  1. Search returning 2 in_kulcare rows
  2. Search returning 0 in_kulcare rows

Expected:

  • Step 1 creates 2 Audit::PhiAccessLog rows with reason_detail.via == 'federated_search', resource_type == 'Identity::Patient', one per result.
  • Step 2 creates 0 audit rows (nothing exposed = nothing audited).

4. Workstream C — POST /patient/appointment_orders

Setup: Patient has no Clinical::Patient at clinic Q.

Steps:

  1. POST /patient/appointment_orders with tenant_id: <Q> and without consent_acknowledged: true.

Expected:

  • 422 with error.code == 'consent_required'
  • No new clinical.patients row created (transaction rolled back)
  • No Audit::ConsentAuditLog row created

Setup: Same as 4.1.

Steps:

  1. POST /patient/appointment_orders with tenant_id: <Q>, all required fields, consent_acknowledged: true.

Expected:

  • 201 Created
  • One new clinical.patients row in tenant Q linked to current user
  • One new clinical.appointments row
  • One Audit::ConsentAuditLog row with:
    • consent_type: 'data_sharing'
    • action: 'granted'
    • collection_method: 'electronic'
    • ip_address matches request remote_ip
    • granted_to.facility_id matches the booking
    • scope.via == 'patient_app_first_time_booking'
    • scope.user_id == subject_user.id
  • One Audit::PhiAccessLog row with reason_detail.via == 'patient_app_authenticated_booking'
  • Response includes meta.first_time_at_facility: true
  • New appointment shows up in GET /patient/appointments

Setup: Patient already has Clinical::Patient at clinic R from a previous booking.

Steps:

  1. POST /patient/appointment_orders with tenant_id: <R>, all required fields. consent_acknowledged omitted.

Expected:

  • 201 Created
  • No new clinical.patients row
  • No new Audit::ConsentAuditLog or Audit::PhiAccessLog rows
  • Response: meta.first_time_at_facility: false

4.4 Status passthrough rejection

Setup: First-time booking at clinic Q.

Steps:

  1. POST /patient/appointment_orders with consent_acknowledged: true, status: 'fulfilled'.

Expected:

  • 201 Created (request succeeds)
  • The created AppointmentOrder has status: 'pending' — caller-supplied status: 'fulfilled' is silently ignored because the controller's permit list doesn't include :status. Only the gateway callback advances an order past pending.
  • No clinical.appointments row yet. Since #1841 this endpoint creates an order and a checkout session; OrderFulfillmentJob materialises the appointment after payment succeeds.

4.5 Acting vs subject user (guardian-on-behalf-of)

Setup: Guardian user has dependent. Dependent has no Clinical::Patient at clinic Q. The dependent is phoneless — identity.patients.phone_number IS NULL (see 1.1 fixtures).

Steps:

  1. POST /patient/appointment_orders with guardian JWT + X-On-Behalf-Of: <dependent_id> header + consent_acknowledged: true.

Expected:

  • 201 Created
  • New Clinical::Patient created in clinic Q linked to the dependent's user_id
  • The new row has phone_number IS NULL and identity_patient_id == the dependent's identity.patients.id
  • Audit::ConsentAuditLog.scope.user_id == dependent's id
  • Audit::ConsentAuditLog.scope.acting_user_id == guardian's id
  • Audit::PhiAccessLog.reason_detail.acting_user_id == guardian's id

Regression watch (#1864): before the fix this returned 422 Could not provision patient record for every dependent, because the flow wrote a synthetic m:<hex> placeholder phone that Clinical::Patient's digits-only validation rejected. A 422 here means the placeholder has come back.

4.6 Tenant validation

Steps:

  1. POST with non-existent tenant_id.
  2. POST with suspended tenant_id.
  3. POST with facility_id belonging to a different tenant.
  4. POST with facility_id of a closed facility (closed_at < now).

Expected:

  • 1: 404, error.code == 'tenant_not_found'
  • 2: 404, error.code == 'tenant_not_found'
  • 3: 404, error.code == 'facility_not_found'
  • 4: 404, error.code == 'facility_not_found' (via facility.operational? check)

4.7 RLS context cleanup

Steps:

  1. Force a failure inside the booking transaction (e.g., supply an invalid practitioner_id).
  2. Immediately follow with a query that depends on app.current_tenant_id being unset.

Expected:

  • The booking returns the appropriate failure
  • Subsequent queries see app.current_tenant_id empty (the ensure block in with_rls_context cleared it even on raise)
  • No leak of tenant context to subsequent requests on the same connection

4.8 Concurrent first-time bookings

Setup: Patient has no Clinical::Patient at clinic Q.

Steps:

  1. Submit two POST /patient/appointment_orders for clinic Q concurrently (same user, both with consent_acknowledged: true).

Expected:

  • One booking succeeds with first_time_at_facility: true
  • The other either succeeds with first_time_at_facility: false (re-read found the row provisioned by the winner) or fails cleanly with a knowable error
  • Exactly one Clinical::Patient row in tenant Q for this user
  • No PG::InFailedSqlTransaction errors in logs (savepoint absorbs the RecordNotUnique)

4.9 Pre-existing appointment race

Setup: Patient has Clinical::Patient at clinic Q. Practitioner has a slot booked.

Steps:

  1. POST /patient/appointment_orders with the same slot start_time as an existing appointment.

Expected:

  • 422 with error.code == 'appointment_create_failed' and the actual conflict message preserved (e.g., "Practitioner is unavailable" or "Slot is already booked")
  • No new Clinical::Patient row (this is a returning patient anyway)
  • No Audit rows

5. Cross-workstream integration

5.1 End-to-end: search → book → list

Setup: Patient registered via patient app. Identity::User exists with phone.

Steps:

  1. From clinic A: GET /clinical/patients/federated_search?phone=<phone>&country_code=91. Verify in_kulcare row.
  2. From clinic A: POST /clinical/appointment_orders with identity_patient_id from step 1.
  3. From patient app (logged in as the patient): GET /patient/appointments.

Expected:

  • Step 2 creates the Clinical::Patient + appointment in clinic A
  • Step 3 returns the appointment from step 2 in the patient's list, with clinic_details populated for clinic A

5.2 Audit trail across workstreams

After step 5.1, query audit.phi_access_logs and audit.consent_audit_logs:

SELECT created_at, resource_type, access_type, reason_detail
FROM audit.phi_access_logs
WHERE tenant_id = '<clinic_a_id>'
ORDER BY created_at;

Expected timeline:

  1. B1 log when federated search returned the in_kulcare row (resource_type: 'Identity::Patient', access_type: 'view')
  2. B2 log when auto-provision created the Clinical::Patient (resource_type: 'Clinical::Patient', access_type: 'amend')
  3. Existing patient-history log when the appointment was created

5A. Post-review hardening (May 2026)

Follow-ups from review of the original three workstreams (#1344). Shipped in PRs #1438, #1436, #1439. Verify each scenario alongside the workstream it tightens.

5A.1 Patient app — clinic-internal note no longer surfaces cross-tenant (#1438)

The patient-facing appointment payload used to ship Clinical::Appointment#note — a free-text field clinic staff historically treated as clinic-internal ("non-compliant," "drug-seeking," suspected-diagnosis shorthand). Cross-tenant fan-out meant another clinic's internal commentary landed on the patient's screen.

Setup: Patient has appointments at clinics A and B. A clinic-A staff member writes the following on one of those appointments:

  • note: "Internal flag — patient missed last two appointments"
  • patient_instruction: "Fast 12 hours before lab"

Steps:

  1. GET /patient/appointments with patient JWT.

Expected:

  • 200 OK.
  • The clinic-A appointment row contains:
    • patient_instruction: "Fast 12 hours before lab" ✅ still surfaced (explicitly patient-facing).
    • cancellation_reason key present (null in this case).
    • No note key on the row.
  • Patient app ClinicAppointmentCard displays the appointment using chief_complaint (derived from reason_codes[0]); the historical fallback to note shows nothing extra for this row, which is the intended behaviour.

Negative check (regression guard):

curl -s -H "Authorization: Bearer <patient_jwt>" \
  https://api-stg.kulcare.com/patient/appointments | \
  jq '.data[] | select(has("note"))'

→ Output must be empty. If any row still ships note, the fix has regressed.

Spec: backend/domains/patient/spec/blueprints/appointment_blueprint_spec.rb — has a positive assertion on not_to have_key(:note).

5A.2 Federated search — identity_user_id no longer leaked (#1436)

The in_kulcare bucket used to carry identity_user_id — a stable global handle that downstream endpoints (POST /clinical/appointment_orders) accept as an actor-of-record. Shipping it from a per-clinic search let a clinic-side caller replay it cross-flow.

Setup: Identity::Patient P exists in clinic B with a linked Identity::User. Clinic A has no record of P.

Steps:

  1. From clinic A: GET /clinical/patients/federated_search?phone=<P.phone>&country_code=91.

Expected:

  • 200 OK.
  • data.in_kulcare[0] contains exactly these keys: identity_patient_id, name, phone, phone_country_code, age, gender, registered_via.
  • No identity_user_id key.
  • registered_via: "patient_app" (because the patient has a linked Identity::User — this is now the only signal of patient-app provenance).

Negative check:

curl -s -H "Authorization: Bearer <clinic_a_staff_jwt>" \
  "https://api-stg.kulcare.com/clinical/patients/federated_search?phone=<phone>&country_code=91" | \
  jq '.data.in_kulcare[] | keys'

→ The resulting key list must not include identity_user_id.

Spec: backend/domains/clinical/spec/services/patients/federated_search_service_spec.rb — positive assertion not_to have_key(:identity_user_id) on both the linked-user and unlinked-user cases.

5A.3 CMS FederatedPatientPicker building blocks (#1439)

Reusable hook + component shipped to @kulcare/shared-services and @kulcare/cms respectively. Not yet wired into any flow — these are building blocks future intake-flow work will consume. QA scope is limited to verifying the contracts hold.

Component contracts

ContractVerify
Hook short-circuits below input floorsType 1, 12, 123 in phone mode → no request fires (check Network tab). Type 1234 → request fires.
Name mode thresholdType a, ab in name mode → no request. Type abc → request fires.
Email floorType a, a@, a@b → no request. Type a@b.c → request fires.
Phone mode without countryCode propPicker renders inline text-warning hint: "Pass a countryCode prop to enable phone-mode search." User does not see indefinite empty state.
+91 country code is normalizedPass countryCode="+91" to picker → search succeeds (not ++91 URL-encoded).
Phone renderingphone_country_code stored as "+91" and as "91" both display as +91 ... (never ++91 ...).
Privacy contractin_kulcare rows show name, phone, age, gender, and one of two provenance strings — never a tenant name, facility name, or other-clinic identifier.
Error statesReturning a 403 from /clinical/patients/federated_search (e.g. revoke federated_search permission) shows "Your clinic doesn't have federated search access — contact your admin." (not the generic retry copy). A 422 shows "Check your search input". Network error shows the retry copy.
"Add new patient" CTAWhen onCreateNew is wired, the CTA renders both when buckets are empty AND when they have matches — supports the "patient with similar name to someone in results" intake case.

Backend audit double-write check

The picker deliberately does not call hipaaAuditService.logPatientEvent for in_kulcare rendering — the backend already emits one Audit::PhiAccessLog row per row returned (the B1 log from §3.7 / #1344 item 9).

Verify: open the picker and trigger a search that returns 3 in_kulcare rows. Query:

SELECT count(*) FROM audit.phi_access_logs
WHERE resource_type = 'Identity::Patient'
  AND created_at > NOW() - INTERVAL '1 minute';

→ Expect 3 rows, not 6. If you see 6, a frontend audit handler has been added in error and is double-writing.

5A.4 Audit::Dispatch envelope (#1441 / #1338 item 1)

All 7 audit-emitting call sites in domains/ now write through Audit::Dispatch. The migration shouldn't change observable row content — same metadata, scope, granted_to, data_categories. Verify by re-running the §3.7 (B1) and §4.x (consent) scenarios and inspecting rows:

SELECT id, resource_type, access_type, user_role, metadata
FROM audit.phi_access_logs
WHERE created_at > NOW() - INTERVAL '5 minutes'
ORDER BY created_at DESC LIMIT 10;

Expected (regression checks):

ScenarioRow should carry
Federated search returns N in_kulcare rowsN PhiAccessLog rows, metadata->>'via' = 'federated_search', metadata->>'matched_via' non-empty, metadata->>'request_id' populated from the calling request's X-Request-ID
Clinic-side POST /clinical/appointment_orders with identity_patient_id for a brand-new in-tenant patientOne PhiAccessLog row, metadata->>'via' = 'federated_search_auto_provision', access_type = 'amend'
Patient-app POST /patient/appointment_orders for first-time facility (consent path)One ConsentAuditLog (data_sharing/granted) + one PhiAccessLog (via = 'patient_app_authenticated_booking')
Public booking phone-match that links to an existing Identity::Patient at another tenantOne ConsentAuditLog with scope->>'via' = 'public_booking_phone_match', scope->>'request_id' populated
Discharge createOne PhiAccessLog row, user_role = 'practitioner' (not 'unknown' — explicit role required after silent-failure review)

Negative regression check:

SELECT count(*) FROM audit.phi_access_logs
WHERE created_at > NOW() - INTERVAL '1 hour'
  AND user_role = 'unknown';

→ Expect 0 rows for the production-shape flows above. 'unknown' is the Dispatch fallback when only actor_id is known AND no actor_role is passed; the current call sites all pass enough info to avoid it. A non-zero count means a call site regressed to id-only without explicit role.

Sidekiq context check (compliance-critical):

If you run any Sidekiq job that touches PHI on staging, confirm the job sets Shared::Current.tenant = job.tenant (or passes tenant: explicitly to Dispatch). Run a job, inspect the PhiAccessLog row's tenant_id, confirm it matches the job's intended tenant. The Dispatch fallback to Shared::Current is safe only when the worker hygiene holds.

Audit::Dispatch.log_consent deliberately doesn't fall back — it requires tenant: explicitly, so this risk is contained to log_phi_access callers.

5A.5 New-tenant wizard RLS context (#1443 / #1387)

PR #1443 fixes a PostgreSQL RLS blocker that prevented existing-tenant users from creating new tenants via the Connect dashboard's "New practice" flow. The dependent INSERTs (identity.organizations / facilities / user_memberships) have WITH CHECK clauses that reject inserts whose tenant_id doesn't match app.current_tenant_id. The wizard now switches the GUC to the new tenant before those INSERTs run, then restores the caller's context after.

Test scenarios:

StepExpected
User logs into existing Tenant A as owner/adminJWT carries tenant_id=A. Active GUC = A.
User clicks "New practice" on Connect DashboardWizard opens at /connect/facility/new?force_new_tenant=true (PR #1386).
User completes wizard (About → Address → Team → Ops → Review → Plan)All fields collected as wizard payload.
Wizard submits to POST /identity/facilities/setup with force_new_tenant: trueReturns 201 with { data: { tenant: <new B>, organization, facility } }.
Frontend switchFacility(B)Subsequent requests run under Tenant B context.
User navigates to CMS dashboardEmpty CMS state (Tenant B has no patients/appointments/etc. yet).

Negative regression checks (must NOT happen):

-- After a successful "New practice" wizard run, the new tenant and
-- its dependents must all carry the SAME tenant_id.
WITH last_tenant AS (
  SELECT id FROM identity.tenants
  WHERE slug = '<the slug from the wizard payload>'
)
SELECT
  (SELECT count(*) FROM identity.organizations WHERE tenant_id = (SELECT id FROM last_tenant)) AS org_count,
  (SELECT count(*) FROM identity.facilities WHERE tenant_id = (SELECT id FROM last_tenant)) AS facility_count,
  (SELECT count(*) FROM identity.user_memberships WHERE tenant_id = (SELECT id FROM last_tenant)) AS membership_count;

→ Expect org_count = 1, facility_count = 1, membership_count = 1. If any are 0, the wizard transaction silently rolled back partway (the RLS fix is broken).

Negative on the caller's context: After the wizard succeeds and the frontend issues switchFacility(B), navigate back to Tenant A in another tab (with the original session). Confirm Tenant A's data is still visible — i.e. creating Tenant B didn't leak the GUC and break Tenant A's session.

Failure-path observability check: On staging, force a wizard failure (e.g. by passing a duplicate slug, or a malformed practitioner payload that triggers Identity::PractitionerRole validation). Confirm:

  1. The response is 4xx with a structured error.
  2. SELECT count(*) FROM identity.tenants WHERE name = '<wizard name>' returns 0 — the failed tenant did NOT persist.
  3. The caller's session can still hit /identity/profiles and see their original Tenant A.

5A.6 Patient app — hospital search wiring (#1444 / #1226)

PR #1444 replaces the mock-only searchHospitals stub with a real call to GET /identity/facilities/search. Before this fix, toggling the Hospital tab on the patient app's search page either rendered a hardcoded fixture (regardless of filter input) or rendered nothing at all — the user-visible behavior in #1226 ("Book Appointment & Filters Issues") was: hospital toggle shows no UI; filter selections never change the doctor list either.

Setup:

  • Staging patient app at /patient route.
  • Backend GET /identity/facilities/search returning at least one facility for city=<city> and one for q=<facility name> — verify via curl first if unsure.
  • Browser DevTools Network panel open.

Test scenarios:

StepExpected
Open patient app search page (default state — Doctors tab)Doctors render via /identity/practitioners/search.
Click the Hospital toggleNetwork panel shows a GET /identity/facilities/search?page=1&per_page=10 (no mock data; URL hits the real backend).
Hospital cards renderEach card shows name, city, beds_count (if present), service_level badge (if present), and any specialities.
Type a city in the location filterNew GET /identity/facilities/search?city=<city>&page=1&per_page=10 fires. Results scope to that city.
Select a speciality filterNew request carries specialty=<speciality name> (note: backend uses American spelling). Results filter accordingly.
Toggle appointmentType = virtualNew request carries telemedicine=true (facilities endpoint; note: the practitioner endpoint uses teleconsultation=true — different param names by design).
Search box: type a hospital nameNew request carries q=<term>. Results filter by name.

Negative regression checks (must NOT happen):

  • The Hospital tab must NEVER return more than per_page results in one page. (Earlier mock returned the entire fixture in one shot — that's the regression to watch for.)
  • The DevTools Network panel must NEVER show mockHospitals.ts being hot-reloaded — that file is deleted. If you see references to getMockHospitals in the source map, the build is stale.
  • A hospital with specialties: ["Cardiology", "Neurology"] in the backend response must render those badges in the UI. (Backend emits American specialties; frontend reads British specialities. The fix maps response.specialties → card.specialities via mapHospitalSpecialties. If the badges don't render even though the backend payload has them, the mapper has regressed.)
  • Facilities with latitude: null, longitude: null (PG-fallback rows) must still render — the card must not crash. Verify by switching backend to PG fallback (Typesense disabled) in a dev env.

Sanity check:

# Confirm searchHospitals is no longer a mock
grep -n "simulateDelay\|getMockHospitals\|mockHospitals" patient/src/features/search/services/search.service.ts
# Expected: no matches.

# Confirm specialities mapping is in place
grep -n "mapHospitalSpecialties\|response.specialties" patient/src/features/search/services/search.service.ts
# Expected: at least one match showing American → British mapping.

PR #1445 closes "Upcoming appointment not showing in patient app" from #1226. CMS staff creates a patient whose phone matches an existing Identity::User (the patient already has a patient-app account); the CMS books an appointment; the patient opens the app — and the appointment is invisible.

Root cause: Clinical::PatientsController#create was doing a bare Clinical::Patient.new(...).save, so the new row landed with identity_patient_id: nil AND user_id: nil. Patient::Appointments::ListService queries Clinical::Patient.where(user_id: <identity_user_id>) — an orphan row is invisible to the cross-tenant aggregator.

The fix keeps the bare-create path (preserving the full CMS form surface — mrn, middle_name, display_name, address, emergency_contact, communication_preferences, etc.) and adds a post-save link_to_global_identity step that, when phone is present, attaches the new clinical row to the global Identity::Patient and (when one exists) the linked Identity::User.

Setup:

  • Two tenants: Tenant A (where CMS staff is logged in) and Tenant B (irrelevant for this test, but useful for the negative check).
  • An existing patient-app user: Identity::User with phone +919999900001, registered through patient-app OTP (so Identity::Patient already exists too).
  • CMS staff at Tenant A logged in with JWT.

Test scenarios:

StepExpected
CMS staff opens "Add patient" modal at Tenant AForm renders.
Fill out: first_name, last_name, phone=9999900001, phone_country_code=+91, gender, dob, mrn, address fieldsAll fields collected.
Submit POST /clinical/patientsReturns 201 with { data: { id, user_id: <non-null>, identity_patient_id: <non-null>, ... } }.
CMS books appointment for that patientStandard CMS booking flow.
Patient opens their app, navigates to "Upcoming"The newly booked appointment is visible — same Identity::User perspective.

SQL verification:

-- After CMS create, the new clinical.patients row should be fully linked
-- (NOT orphaned with user_id=nil).
SELECT id, tenant_id, user_id, identity_patient_id, phone_number, first_name
FROM clinical.patients
WHERE phone_number = '9999900001'
ORDER BY created_at DESC
LIMIT 1;
-- Expected: user_id IS NOT NULL, identity_patient_id IS NOT NULL.

-- Verify the link points to the right Identity::User.
SELECT cp.user_id, iu.phone_country_code || iu.phone_number AS user_phone,
       cp.identity_patient_id, ip.phone_country_code || ip.phone_number AS identity_patient_phone
FROM clinical.patients cp
JOIN identity.users iu ON iu.id = cp.user_id
JOIN identity.patients ip ON ip.id = cp.identity_patient_id
WHERE cp.phone_number = '9999900001'
ORDER BY cp.created_at DESC
LIMIT 1;
-- Expected: user_phone = identity_patient_phone = '+919999900001'.

Negative regression checks (must NOT happen):

  • Phoneless POST /clinical/patients (rare, but: dependent infant, John Doe ER intake) must still succeed and return 201. Verify: omit phone_number from the request body. The link step is a no-op for phoneless rows. The resulting row will have user_id: nil, identity_patient_id: nil — that is the correct state for an anonymous / phoneless entry (it simply won't surface in any patient app, since there's no app account to surface it under).
  • The CMS form surface must NOT regress. After this PR, submitting mrn, middle_name, display_name, marital_status, address_line_1, emergency_contact_*, communication_preferences_* must all persist exactly as before. (The earlier abandoned approach of routing through FindOrCreateService silently dropped these fields; the post-save-link approach keeps them intact.)
  • A duplicate-phone race (CMS staff creates John Doe at +919999900001 simultaneously in two browser tabs) must not raise a 500. The link_to_global_identity rescues RecordInvalid / RecordNotUnique and logs a warn; the clinical row still persists.

Audit-trail check:

-- If the phone-matched Identity::Patient was first created at Tenant B,
-- a cross-tenant consent audit row should be emitted on this Tenant A create.
SELECT *
FROM clinical.consent_audit_logs
WHERE patient_user_id = (
  SELECT user_id FROM clinical.patients WHERE phone_number = '9999900001' ORDER BY created_at DESC LIMIT 1
)
ORDER BY created_at DESC
LIMIT 3;
-- Expected: at least one row with action='cross_tenant_link', tenant_id=<Tenant A>.

Note: the cross-tenant consent audit only fires when link_to_global_identity finds an Identity::Patient that was previously created in a different tenant. If the patient was provisioned via patient-app OTP (tenant-agnostic), there's no prior tenant to record consent against and no audit row is emitted — that's correct.

Negative cross-leak check:

  • After CMS at Tenant A creates a patient with phone 9999900001, log in at Tenant B as a different clinic. Navigate to Tenant B's patient list. The Tenant A patient must NOT appear. RLS continues to scope clinical.patients by tenant_id — the identity link is global but the clinical row is per-tenant.

5A.8 Patient app — document View opens with JWT (#1446 / #1226)

PR #1446 fixes the documents-tab "View" button showing a blank tab. The previous implementation rendered <a href="<rails_blob_url>" target="_blank">; the new tab strips the Authorization: Bearer <JWT> header (browsers never propagate auth headers across target="_blank" navigations), so the ActiveStorage redirect endpoint returned 401 and the user saw a blank page.

The fix opens a synchronous window.open('about:blank') inside the click handler (preserving user-gesture context for pop-up blockers), then fetches the document with apiClient (which attaches the JWT), constructs a Blob with the explicit Content-Type from the response, builds an URL.createObjectURL(...) URL, and redirects the already-open popup to it. The blob URL is revoked after 5 minutes to free memory.

Setup:

  • Patient app user logged in (has JWT in localStorage).
  • At least one document attached to the user's records — either patient-uploaded (Patient::Document) or clinical-attached (Clinical::Document surfaced via the medical-records aggregator). Mix of file types: PDF, JPEG, PNG.
  • Browser DevTools Network panel open. Pop-up blocker enabled (Chrome default).

Test scenarios:

StepExpected
Patient app → Documents tabDocument list renders with title, type badge, date, source badge.
Click "View" on a PDF documentA new browser tab opens (pop-up blocker does NOT block — synchronous window.open inside a click handler is whitelisted). Tab title initially shows about:blank, then within a beat redirects to a blob:https://... URL. PDF renders inline.
Click "View" on an image (JPEG/PNG)Same flow. Image renders in the new tab.
Network panelThe rails_blob_url fetch carries Authorization: Bearer <JWT> (visible in request headers). Response carries Content-Type: application/pdf (or image/jpeg etc.).
Click "View" on a patient-uploaded document the user ownsOpens successfully.
Click "View" on a clinical-attached document the user has consented toOpens successfully (same code path; ActiveStorage authz is enforced by the backend Patient::Documents endpoints already).

Negative regression checks (must NOT happen):

  • The fetch must NEVER fall back to a raw <a href="..."> link. Confirm: the DocumentCard.tsx source must not contain <a target="_blank"> for the View button anymore. Verify with grep -n 'target=\"_blank\"' patient/src/features/documents/components/DocumentCard.tsx → expect zero matches.
  • Pop-up blocker must NOT block the new tab. If it does, the window.open call has been moved out of the click-handler's synchronous call frame (e.g. moved after an await). Watch for this regression specifically — the order of operations is load-bearing.
  • The Content-Type must NEVER be text/html for a document fetch. If a 401/302 is returned and the browser treats the HTML body as the content, the user sees a blank page or a Rails error page. Verify: response Content-Type in Network panel matches the original file type.
  • The blob URL must NEVER leak beyond 5 minutes. Hold the tab open for 6+ minutes, then reload — expect the URL to 404 (revoked). The 5-minute revoke timer protects against unbounded memory growth from many opens.
  • Logging out (clearing JWT) and then clicking "View" must NOT silently fail. The apiClient interceptor should redirect to login; the popup should close or show an explicit error toast. Verify: in DevTools, after JWT clear, click View → expect a 401 from the fetch, then the popup is closed and a toast surfaces. (The popup is opened sync, so if the fetch fails after-the-fact, the code closes the popup explicitly — verify no orphan about:blank tab is left behind.)

Cross-browser check:

  • Chrome: pop-up blocker default. Verify View works without granting per-site exception.
  • Firefox: default pop-up settings. Same.
  • Safari: synchronous window.open from a user gesture should work; verify on macOS Safari.
  • Mobile Safari: tap View on a real iPhone (PWA install or web). Verify the new tab opens (iOS Safari can be finicky about window.open — the synchronous open is essential here).

MIME-type preservation check:

ActiveStorage occasionally omits Content-Type on the redirect target. The fix explicitly reads response.headers['content-type'] and reconstructs the Blob with that type. If it's missing, the code falls back to application/octet-stream, which forces a download instead of inline rendering. Verify by uploading a PDF with no explicit content-type set in S3 metadata — it should still render inline, not trigger a "save as" prompt.


5B. Phoneless dependents (August 2026, #1864)

Guardian bookings for a dependent 422'd from 2026-06-26 to 2026-08-17. The dependent flow wrote a synthetic m:<hex> placeholder into identity.patients.phone_number (which was NOT NULL + globally unique), and PR #1735 added a digits-only validation to Clinical::Patient that the placeholder could not satisfy. The column is now genuinely nullable and the placeholder is gone.

5B.1 Dependent creation is phoneless

Steps:

  1. POST /patient/dependents with guardian JWT and no phone_number.

Expected:

  • 201 Created
  • Response data.phone_number is null — not a string, and never m:...
  • identity.patients.phone_number IS NULL for the new row

The patient mobile app is not in this repo. This field previously always held a truthy string for dependents, so confirm the client tolerates null here and on GET /patient/profile, GET /identity/patients/me, and GET/POST /identity/patients/family.

5B.2 Booking twice reuses one Clinical::Patient

Steps:

  1. Book for the same dependent at the same facility twice.

Expected:

  • Exactly one clinical.patients row for that (tenant_id, identity_patient_id)
  • Dedup is by identity_patient_id, not by phone — a phoneless row has no phone to dedup on

5B.3 Shared family phone must not merge charts (PHI)

The important one. A guardian may supply a phone when adding a dependent, and families share numbers.

Setup: Clinic Q has a clinic-created Clinical::Patient for a relative with phone P and user_id IS NULL. Add a dependent whose identity.patients row also carries phone P.

Steps:

  1. Book for the dependent at clinic Q.

Expected:

  • A new clinical.patients row is created for the dependent
  • The relative's row is untouched: its user_id is still NULL
  • The dependent must not be resolved onto the relative's chart

If the dependent's user_id lands on the relative's row, the clinical.patients RLS self-access branch (user_id = app.current_patient_user_id) turns that into a cross-patient PHI read. Treat any occurrence as a P0.

5B.4 Walk-in booking still resolves by phone

Steps:

  1. From CMS, check in / book a walk-in with phone P and no identity supplied.

Expected:

  • Resolves to the existing clinical.patients row with phone P (unchanged behaviour)
  • Only an explicitly supplied identity skips the phone lookup

Setup: AFTER-payment teleconsult for a phoneless dependent whose guardian has a phone.

Expected:

  • The post-consultation payment SMS goes to the guardian's number
  • If neither has a phone, the service fails permanently and the job logs and drops it — it must not retry forever

5B.6 Legacy rows stay editable

Setup: A clinical.patients row whose phone predates #1735 and is not digits-only (e.g. +91 98765-43210).

Steps:

  1. Edit an unrelated field (name, DOB) from CMS.

Expected:

  • Saves successfully. The digits-only format is enforced only when the phone itself changes.
  • Changing that phone to another non-digit value is still rejected.

6. Performance expectations

OperationExpected latency p95
GET /patient/appointments for a 5-tenant patient< 300ms
GET /clinical/patients/federated_search (single phone)< 100ms
POST /patient/appointment_orders (returning patient)< 200ms
POST /patient/appointment_orders (first-time, auto-provision)< 400ms

fan_out is capped at 10 (MAX_TENANTS_PER_REQUEST). Patients with more than 10 affiliations get the most recent 10.


7. Regression checklist

Run before each release that touches patient/clinical domains. Commands are Minitest — domains/ moved off RSpec in #1739, so the old bundle exec rspec backend/domains/**/spec/... invocations no longer resolve. Run them from backend/.

  • bundle exec rails test domains/patient/test/services/appointments/list_service_test.rb — green
  • bundle exec rails test domains/patient/test/services/appointment_orders/create_service_test.rb — green
  • bundle exec rails test domains/clinical/test/services/patients/federated_search_service_test.rb — green
  • bundle exec rails test domains/clinical/test/services/orders/create_appointment_order_service_provisioning_test.rb — green
  • bundle exec rails test domains/patient/test/blueprints/appointment_blueprint_test.rb — green (#1438 regression guard on note)
  • bundle exec rails test domains/audit/test/services/dispatch_test.rb — green (#1441 / #1338 item 1)
  • bundle exec rails test domains/identity/test/services/facilities/wizard_setup_service_new_tenant_test.rb — green (#1443 / #1387)
  • bundle exec rails test domains/shared/test/services/tenancy/rls_context_test.rb — green (#1443 — RlsContext activate/clear/with)
  • Manual: cross-clinic search → 0 leakage of other-clinic identifiers in JSON
  • Manual: GET /patient/appointments response has no note key on any row (#1438)
  • Manual: GET /clinical/patients/federated_search in_kulcare rows have no identity_user_id key (#1436)
  • Manual: FederatedPatientPicker renders the text-warning hint when used in phone mode without countryCode (#1439)
  • Manual: guardian-on-behalf-of booking — audit logs record both user_id (dependent) and acting_user_id (guardian)
  • Manual: book at a closed facility — 404 with facility_not_found
  • Manual: patient-app Hospital toggle fires GET /identity/facilities/search and renders real backend results (#1444)
  • Manual: CMS-created patient with matching phone surfaces in patient-app Upcoming after booking (#1445)
  • SQL: clinical.patients row from CMS create has user_id IS NOT NULL AND identity_patient_id IS NOT NULL when phone is present (#1445)
  • Manual: phoneless CMS patient create still succeeds with user_id IS NULL (#1445 negative)
  • Manual: patient-app Documents "View" opens in new tab with content rendered, no blank page (#1446)
  • Manual: patient-app Documents "View" — pop-up blocker does not block, Network panel shows JWT-authenticated fetch with correct Content-Type (#1446)
  • bundle exec rails test domains/clinical/test/services/patients/find_or_create_service_test.rb — green (#1864 identity-vs-phone resolution)
  • bundle exec rails test domains/clinical/test/migrations/allow_null_phone_on_identity_patients_test.rb — green (#1864 backfill under enforced RLS)
  • Manual: guardian books for a phoneless dependent — 201, not 422 Could not provision patient record (#1864)
  • Manual: dependent sharing a family phone with an existing clinic patient gets its OWN clinical.patients row (#1864 — PHI, see 5B.3)
  • SQL: SELECT count(*) FROM identity.patients WHERE phone_number LIKE 'm:%' returns 0 (#1864 placeholder fully retired)
  • SQL: the partial unique index is valid — SELECT indisvalid FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid WHERE c.relname='idx_patients_phone_unique_not_null' returns t (#1864)

8. Known limitations

LimitationIssue
Audit failures rescued silently — should fail-loudFixed in PR #1352 (#1344 item 7)
Clinical::Practitioners::AvailabilityService references nonexistent columnFixed #1347
Federated search response leaks identity_user_idFixed in PR #1436 (#1344 item 11)
Patient appointment payload exposes clinic-internal note cross-tenantFixed in PR #1438 (#1344 item 4)
No clinic-app frontend for federated searchBuilding blocks shipped in PR #1439 (#1344 item 13). Full intake-flow integration (the in_kulcare → provision-from-identity UX) is deferred to a follow-up.
Audit envelope duplicated across 7 call sites — drift riskFixed in PR #1441 (#1338 item 1). All emitters now route through Audit::Dispatch. See developer/patients/audit/ for contract.
RLS blocks the new-tenant wizardFixed in PR #1443 (#1387). Identity::Facilities::WizardSetupService + SetupService now wrap dependent INSERTs in Shared::Tenancy::RlsContext.with(new_tenant.id). PR #1386 (frontend "New practice" wizard) coordinates separately.
Shared::Current#tenant= callback writes the GUC implicitly — coupling that caused #1387Mitigated by the explicit RlsContext API (PR #1443). Full callback removal + ~48 call-site migration deferred per the decouple plan.
Hospital search on patient app returns mock dataFixed in PR #1444 (#1226). searchHospitals now calls /identity/facilities/search and maps specialties → specialities.
CMS-created patient doesn't appear in patient app after bookingFixed in PR #1445 (#1226). Clinical::PatientsController#create now back-links to Identity::Patient / Identity::User post-save.
Patient app Documents "View" shows blank tabFixed in PR #1446 (#1226). Document fetched with JWT, opened as a blob URL in a synchronously-opened popup.
Audit log retention policy#1338 item 2 — deferred (threshold-based; tables still low volume)
Patient-facing "who's seen my record" UI#1338 item 3 — deferred Q2-Q3
Audit note field exposure — Option D (separate patient_visible_note schema column + CMS UX affordance)Deferred follow-up to #1438. Option B (drop from wire) is the shipped fix.
No patient-app frontend wiring for new /patient/appointment_orders endpoint yet#1346 Phase 4
Public booking page doesn't yet detect existing Identity::User to skip OTP#1346 Phase 3

9. References

On this page

1. Test environment setup1.1 Required fixtures1.2 Auth tokens2. Workstream A — GET /patient/appointments2.1 Cross-tenant aggregation2.2 N+1 absence2.3 Vendor appointment without practitioner2.4 Filtering2.5 LHR endpoint removal2.6 Subject vs current user (guardian on-behalf-of)3. Workstream B — GET /clinical/patients/federated_search3.1 Three-bucket return3.2 Privacy guard — no other-clinic leakage3.3 Phone country_code required3.4 LIKE wildcard sanitization3.5 Auto-provision via identity_patient_id3.6 Auto-provision race-safety3.7 Audit B1 — in_kulcare exposure4. Workstream C — POST /patient/appointment_orders4.1 First-time booking — consent required4.2 First-time booking — consent given4.3 Returning patient — no consent step4.4 Status passthrough rejection4.5 Acting vs subject user (guardian-on-behalf-of)4.6 Tenant validation4.7 RLS context cleanup4.8 Concurrent first-time bookings4.9 Pre-existing appointment race5. Cross-workstream integration5.1 End-to-end: search → book → list5.2 Audit trail across workstreams5A. Post-review hardening (May 2026)5A.1 Patient app — clinic-internal note no longer surfaces cross-tenant (#1438)5A.2 Federated search — identity_user_id no longer leaked (#1436)5A.3 CMS FederatedPatientPicker building blocks (#1439)5A.4 Audit::Dispatch envelope (#1441 / #1338 item 1)5A.5 New-tenant wizard RLS context (#1443 / #1387)5A.6 Patient app — hospital search wiring (#1444 / #1226)5A.7 CMS patient creation — identity-link backfill (#1445 / #1226)5A.8 Patient app — document View opens with JWT (#1446 / #1226)5B. Phoneless dependents (August 2026, #1864)5B.1 Dependent creation is phoneless5B.2 Booking twice reuses one Clinical::Patient5B.3 Shared family phone must not merge charts (PHI)5B.4 Walk-in booking still resolves by phone5B.5 Payment link reaches the guardian5B.6 Legacy rows stay editable6. Performance expectations7. Regression checklist8. Known limitations9. References