Kulcare Docs
Feature QA checklists

Patient Domain QA Guide

Scope: End-to-end test plan for the Patient:: backend module (backend/domains/patient/).

Prerequisites: Dev server running, a valid identity JWT for a test patient user, and a Postgres database with the patient + clinical schemas migrated.

Related: Developer guide · API reference · ADR-002


1. Test environment setup

1.1 Database

cd backend
bundle exec rails db:migrate          # Dev DB
RAILS_ENV=test bundle exec rails db:migrate   # Test DB

Confirm the patient schema has all 10 tables:

psql kulcare_development -c "\dt patient.*"

Expect: allergies, conditions, documents, family_history_entries, health_notes, immunizations, medications, procedures, symptoms, vitals.

1.2 Auth

Obtain a JWT for an existing patient user, or mint one via the identity API. Tests below use an $TOKEN environment variable:

export TOKEN="eyJhbGc..."   # Bearer token for the test patient

Every request must include Authorization: Bearer $TOKEN. Tenant headers (e.g. X-Tenant-Slug) are not required and are ignored by the Patient:: surface.

1.3 Test users

Create at least three Identity::User records for isolation tests:

RolePurpose
OwnerThe user whose records we're testing. Used for the bulk of happy-path tests.
OtherA second patient. Used to verify cross-user 404s — the owner must not see other's records.
No-recordsA brand-new user with no Clinical::Patient row. Used to verify empty-state behavior.

1.4 Clinical fixtures (for cross-tenant tests)

For the /patient/clinical/* tests you need at least one Clinical::Patient row for the owner in at least one tenant. Two tenants if testing cross-tenant aggregation:

# In rails console, as an example
Clinical::Patient.create!(tenant: clinic_a, user_id: owner.id, first_name: 'Test', last_name: 'Patient',
                          gender: :unknown, deceased: false)
Clinical::Patient.create!(tenant: clinic_b, user_id: owner.id, first_name: 'Test', last_name: 'Patient',
                          gender: :unknown, deceased: false)

2. Smoke tests

These should pass before running the full suite.

#TestExpected
S1GET /patient/vitals with no records200, { "data": [], "meta": { "total": 0, ... } }
S2GET /patient/clinical/timeline with no records200, { "data": { "timeline": [], "facilities": [] } }
S3Request with no Authorization header401 unauthorized
S4Request with a malformed token401 unauthorized

3. Patient-reported CRUD (10 resources)

The 10 resources — vitals, symptoms, medications, allergies, conditions, immunizations, family_history_entries, procedures, health_notes, documents — share the same CRUD surface. Run this matrix against each.

3.1 Create

ScenarioRequestExpected
Valid self-reportedMinimum-valid payload with source omitted201, row created with source: 'self_reported', user_id = caller, recorded_by_user_id = caller
Unknown enum valuevital_type: "nonsense" (or equivalent per-resource enum)422, error detail on the enum field
source: 'clinical_imported' without referencePayload with source: 'clinical_imported', source_reference: null422, error on source_reference — "must be present when source is clinical_imported"
source: 'device' without referenceSimilar422
recorded_by_user_id set to another user's idTry to spoof authorship422, error: "must equal user_id (caregiver authorship is not yet supported)"

3.2 Read

ScenarioRequestExpected
List own recordsGET /patient/<resource>200, only caller's rows
List excludes other user's rowsCreate a row as Other, list as OwnerOther's row is absent from the response
List excludes soft-deleted rows by defaultSoft-delete a row, then listRow absent
List includes deleted with ?include_deleted=trueSame as above + query paramRow present
Show own recordGET /patient/<resource>/:id200, row payload
Show other user's recordAs Owner, GET id belonging to Other404 (not 403 — existence must not leak)
Show nonexistent idRandom UUID404

3.3 Update

ScenarioRequestExpected
Own rowPATCH with valid changes200, changes persisted
Other's rowPATCH id belonging to Other404, original row untouched
Invalid enum on updateChange severity to "nonsense"422

3.4 Delete + Restore

ScenarioRequestExpected
Delete ownDELETE /patient/<resource>/:id204, deleted_at set, row absent from default listing
Delete other'sDELETE id belonging to Other404, Other's row still active
Restore ownPOST /patient/<resource>/:id/restore200, deleted_at cleared
Restore other'sPOST to restore Other's soft-deleted row404

3.5 Resource-specific per-field

Spot-check one or two fields per resource that have special behavior:

ResourceFieldVerify
Vitalsvalue jsonb{systolic: 120, diastolic: 80} for BP, {value: 98.6} for others, round-trip through create → show
Vitalsrecorded_at aliasSend as recorded_at, expect occurred_at on read
Symptomstriggers[], relief_methods[]Array round-trip
Medicationsadherence_log jsonbArray of {taken_at, status, ...} round-trip
Conditionsis_chronicBoolean round-trip
Family historyNo occurred_atList sorted by created_at (no error)
Health notesrecorded_at aliasSame as vitals

4. Bulk vitals ingestion

POST /patient/vitals/bulk — designed for retry-safe device sync (Apple Health, Fitbit, BP cuff).

#ScenarioExpected
B1Fresh batch of N valid rows with unique source_reference.sample_id values201, created_count: N, skipped_count: 0, N rows in DB
B2Replay the same batch201, created_count: 0, skipped_count: N, DB count unchanged
B3Partial replay (2 new + 2 duplicates)201, created_count: 2, skipped_count: 2
B4Batch with one invalid row422, row-indexed errors ("field": "row[N]"), DB count unchanged
B5Empty batch ({ "vitals": [] })422 or 201 with created_count: 0 (verify current behavior; service returns error for empty)
B6Batch missing source_reference on source: 'device' rows422 per-row
B7Batch of 100+ rows201, completes in < 1s locally

Sample cURL

curl -X POST http://localhost:3000/patient/vitals/bulk \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "vitals": [
      {
        "vital_type": "heart_rate",
        "value": {"value": 72},
        "occurred_at": "2026-04-22T08:00:00Z",
        "source": "device",
        "source_reference": {"type": "device", "vendor": "apple_health", "sample_id": "hk-abc-1"}
      }
    ]
  }'

5. Documents (ActiveStorage)

Patient::DocumentsController handles multipart file uploads.

#ScenarioExpected
D1Create with file201, document row created, file attached, file_url in response points to a /rails/active_storage/blobs/... path
D2Create metadata-only (no file)201, row created without attachment, file_url is null
D3Update + attach file on existing row200, file now attached on next GET
D4Delete row204, row soft-deleted (blob stays in bucket — Phase 6 follow-up will hard-purge on user deletion)
D5file_url accessible with caller's signed tokenDownloadable
D6file_url accessible by another userTreat as implementation-defined (signed URLs are short-lived; production has separate auth). For QA, confirm cross-user retrieval is not trivially possible.

Sample multipart

curl -X POST http://localhost:3000/patient/documents \
  -H "Authorization: Bearer $TOKEN" \
  -F "document[title]=Lab report Jan 2026" \
  -F "document[document_type]=lab_report" \
  -F "file=@./sample_lab.pdf"

6. Clinical-read façade (cross-tenant)

/patient/clinical/:resource_type for list, /patient/clinical/:resource_type/:id for show.

6.1 Resource dispatcher

#ScenarioExpected
C1GET /patient/clinical/conditions with 1 tenant record200, rows for that tenant only
C2GET /patient/clinical/conditions spanning 2+ tenants200, merged rows, meta.fan_out equals number of tenants
C3GET /patient/clinical/<unknown>404 at route layer
C4GET /patient/clinical/encounters for new patient (no clinical records)200, data: [], meta.total: 0
C5GET /patient/clinical/encounters?limit=10&page=1200, respects pagination; meta.page, meta.limit, meta.pages populated
C6GET /patient/clinical/conditions/:id for owned record200, full record
C7GET /patient/clinical/conditions/:id for another patient's id404
C8GET /patient/clinical/observations?from=2026-01-01&to=2026-12-31200, filtered by date

6.2 Resource-type coverage

Run at least C1 / C2 / C4 / C7 for each of the 7 clinical types:

  • conditions
  • prescriptions
  • allergy_intolerances
  • observations
  • encounters
  • appointments
  • documents

6.3 Timeline

#ScenarioExpected
T1GET /patient/clinical/timeline for a new patient200, data.timeline: [], data.facilities: []
T2Timeline with records across 2 tenants200, data.facilities has 2 entries, data.timeline is merged and sorted by date DESC
T3Timeline ?limit=5data.timeline.length <= 5
T4Timeline with a mix of resource typesContains type: 'encounter', type: 'prescription', type: 'condition', etc.

6.4 Fan-out budget

#ScenarioExpected
F1User with 10 Clinical::Patient rows200, service iterates exactly 10 tenants
F2User with 11+ rows200, first 10 tenants only (capped by MAX_TENANTS_PER_REQUEST). Note: ensure this limit is documented in a future "sharing & consolidation" design — the current cap protects the API from pathological fan-out but may hide records from a user with many clinics.

7. Security test cases

Critical: these must all pass before any release.

7.1 Owner isolation

#ScenarioExpected
SEC1List, show, update, delete, restore — every endpoint, with Other's id404 for every verb
SEC2Create a row with user_id set to Other's id in the payloaduser_id is ignored — row created with user_id = current_user.id. The API must not accept a client-specified user_id override.
SEC3recorded_by_user_id set to Other's id in payload422, v1 guard rejects
SEC4Bulk create — verify caller's user_id is stamped on every rowEvery created row has user_id = caller regardless of payload

7.2 Authentication

#ScenarioExpected
SEC5No Authorization header401 unauthorized for every endpoint
SEC6Expired / revoked token401
SEC7Malformed Bearer header (wrong prefix, no token)401

7.3 Tenant leakage

#ScenarioExpected
SEC8Request with an X-Tenant-Slug header set to another tenantIgnored — tenant context is not set on Patient:: requests
SEC9Cross-tenant read service raises mid-iterationExisting RLS context restored; subsequent requests are not polluted. Verify via spec: longitudinal_record_service_spec.rb "RLS context restoration on exception".

7.4 Source integrity

#ScenarioExpected
SEC10Import the same clinical record twice via source: 'clinical_imported' with identical source_referenceSecond attempt: 422 (or DB unique violation surfaced as validation error). Partial unique index enforces idempotency.
SEC11Bulk sync the same sample_id twiceSecond batch: 0 created, N skipped

8. Error & edge cases

#ScenarioExpected
E1Create vital with value that isn't jsonb (e.g. raw number)422 or strong params reject
E2Create with unknown top-level keysKeys silently dropped by strong params
E3PATCH with no body422 (or 400 depending on Rails config) — no server 500
E4Upload a 50 MB documentSee Phase 6 follow-up — not yet size-limited at the app layer
E5Non-JSON Content-Type on POSTLikely 406 or 415 — not 500
E6Very long query string (?from=...&to=... with huge values)422 on validation, not 500
E7Simultaneous bulk posts with overlapping sample_idDB unique index serializes; no duplicates

9. Performance expectations

Local development numbers — not production SLOs.

OperationExpected latencyNotes
Patient-reported list (N≤100)< 50msSingle-table query
Patient-reported create< 50msSingle insert
Bulk vitals (100 rows)< 200msSingle insert_all with ON CONFLICT
Clinical list (1 tenant)< 100msOne RLS switch
Clinical list (5 tenants)< 300msFive RLS switches
Timeline (5 tenants × 7 resource types × 50 limit)< 500msP95 target per plan
Document create with 2 MB PDF< 500msAttach + blob store

If any of these blow out by >2×, profile LongitudinalRecordService#with_rls_context and per-tenant queries before suspecting application code.


10. Regression checklist

Before merging any PR that touches Patient::* or Clinical::Patients::LongitudinalRecordService:

  • bundle exec rspec domains/patient/spec/ — all green
  • bundle exec rspec domains/clinical/spec/services/patients/longitudinal_record_service_spec.rb — all green
  • bundle exec rubocop domains/patient/ — no offenses
  • Manual smoke (§2) on the dev server
  • One CRUD matrix run on one resource (§3) if the change touches the controller/concern/policy base
  • Bulk idempotency (§4 B2, B3) if the change touches vitals or the bulk service
  • Cross-tenant aggregation (§6.1 C2, §6.3 T2) if the change touches LongitudinalRecordService
  • Security isolation (§7.1 SEC1) — non-negotiable on any policy / scope change
  • structure.sql diff is scoped and reviewable if a migration was added

11. Known limitations (out of scope for current QA cycle)

These are documented in ADR-002 as deferred decisions. QA should confirm the absence of these features rather than test them.

FeatureStatusExpected current behavior
Explicit patient-to-facility sharingDeferredPatient-reported data is visible only to the patient; no share endpoint exists
Caregiver / family-member authorshipDeferredrecorded_by_user_id must equal user_id; cross-user authorship rejected
Right-to-erasure / GDPR hard-deleteDeferredFKs are ON DELETE RESTRICT; deleting a user whose rows exist returns a DB error
Document file size limitsDeferred to Phase 6 ops workNo app-layer size validation (rely on web server / S3 limits)
Virus scanning on uploadsDeferredFiles are stored as-is
Pagination cursor for cross-tenant readsDeferredOffset-based pagination with 10-tenant fan-out cap; adequate for typical usage

12. References

On this page