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 DBConfirm 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 patientEvery 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:
| Role | Purpose |
|---|---|
| Owner | The user whose records we're testing. Used for the bulk of happy-path tests. |
| Other | A second patient. Used to verify cross-user 404s — the owner must not see other's records. |
| No-records | A 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.
| # | Test | Expected |
|---|---|---|
| S1 | GET /patient/vitals with no records | 200, { "data": [], "meta": { "total": 0, ... } } |
| S2 | GET /patient/clinical/timeline with no records | 200, { "data": { "timeline": [], "facilities": [] } } |
| S3 | Request with no Authorization header | 401 unauthorized |
| S4 | Request with a malformed token | 401 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
| Scenario | Request | Expected |
|---|---|---|
| Valid self-reported | Minimum-valid payload with source omitted | 201, row created with source: 'self_reported', user_id = caller, recorded_by_user_id = caller |
| Unknown enum value | vital_type: "nonsense" (or equivalent per-resource enum) | 422, error detail on the enum field |
source: 'clinical_imported' without reference | Payload with source: 'clinical_imported', source_reference: null | 422, error on source_reference — "must be present when source is clinical_imported" |
source: 'device' without reference | Similar | 422 |
recorded_by_user_id set to another user's id | Try to spoof authorship | 422, error: "must equal user_id (caregiver authorship is not yet supported)" |
3.2 Read
| Scenario | Request | Expected |
|---|---|---|
| List own records | GET /patient/<resource> | 200, only caller's rows |
| List excludes other user's rows | Create a row as Other, list as Owner | Other's row is absent from the response |
| List excludes soft-deleted rows by default | Soft-delete a row, then list | Row absent |
List includes deleted with ?include_deleted=true | Same as above + query param | Row present |
| Show own record | GET /patient/<resource>/:id | 200, row payload |
| Show other user's record | As Owner, GET id belonging to Other | 404 (not 403 — existence must not leak) |
| Show nonexistent id | Random UUID | 404 |
3.3 Update
| Scenario | Request | Expected |
|---|---|---|
| Own row | PATCH with valid changes | 200, changes persisted |
| Other's row | PATCH id belonging to Other | 404, original row untouched |
| Invalid enum on update | Change severity to "nonsense" | 422 |
3.4 Delete + Restore
| Scenario | Request | Expected |
|---|---|---|
| Delete own | DELETE /patient/<resource>/:id | 204, deleted_at set, row absent from default listing |
| Delete other's | DELETE id belonging to Other | 404, Other's row still active |
| Restore own | POST /patient/<resource>/:id/restore | 200, deleted_at cleared |
| Restore other's | POST to restore Other's soft-deleted row | 404 |
3.5 Resource-specific per-field
Spot-check one or two fields per resource that have special behavior:
| Resource | Field | Verify |
|---|---|---|
| Vitals | value jsonb | {systolic: 120, diastolic: 80} for BP, {value: 98.6} for others, round-trip through create → show |
| Vitals | recorded_at alias | Send as recorded_at, expect occurred_at on read |
| Symptoms | triggers[], relief_methods[] | Array round-trip |
| Medications | adherence_log jsonb | Array of {taken_at, status, ...} round-trip |
| Conditions | is_chronic | Boolean round-trip |
| Family history | No occurred_at | List sorted by created_at (no error) |
| Health notes | recorded_at alias | Same as vitals |
4. Bulk vitals ingestion
POST /patient/vitals/bulk — designed for retry-safe device sync (Apple Health, Fitbit, BP cuff).
| # | Scenario | Expected |
|---|---|---|
| B1 | Fresh batch of N valid rows with unique source_reference.sample_id values | 201, created_count: N, skipped_count: 0, N rows in DB |
| B2 | Replay the same batch | 201, created_count: 0, skipped_count: N, DB count unchanged |
| B3 | Partial replay (2 new + 2 duplicates) | 201, created_count: 2, skipped_count: 2 |
| B4 | Batch with one invalid row | 422, row-indexed errors ("field": "row[N]"), DB count unchanged |
| B5 | Empty batch ({ "vitals": [] }) | 422 or 201 with created_count: 0 (verify current behavior; service returns error for empty) |
| B6 | Batch missing source_reference on source: 'device' rows | 422 per-row |
| B7 | Batch of 100+ rows | 201, 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.
| # | Scenario | Expected |
|---|---|---|
| D1 | Create with file | 201, document row created, file attached, file_url in response points to a /rails/active_storage/blobs/... path |
| D2 | Create metadata-only (no file) | 201, row created without attachment, file_url is null |
| D3 | Update + attach file on existing row | 200, file now attached on next GET |
| D4 | Delete row | 204, row soft-deleted (blob stays in bucket — Phase 6 follow-up will hard-purge on user deletion) |
| D5 | file_url accessible with caller's signed token | Downloadable |
| D6 | file_url accessible by another user | Treat 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
| # | Scenario | Expected |
|---|---|---|
| C1 | GET /patient/clinical/conditions with 1 tenant record | 200, rows for that tenant only |
| C2 | GET /patient/clinical/conditions spanning 2+ tenants | 200, merged rows, meta.fan_out equals number of tenants |
| C3 | GET /patient/clinical/<unknown> | 404 at route layer |
| C4 | GET /patient/clinical/encounters for new patient (no clinical records) | 200, data: [], meta.total: 0 |
| C5 | GET /patient/clinical/encounters?limit=10&page=1 | 200, respects pagination; meta.page, meta.limit, meta.pages populated |
| C6 | GET /patient/clinical/conditions/:id for owned record | 200, full record |
| C7 | GET /patient/clinical/conditions/:id for another patient's id | 404 |
| C8 | GET /patient/clinical/observations?from=2026-01-01&to=2026-12-31 | 200, 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
| # | Scenario | Expected |
|---|---|---|
| T1 | GET /patient/clinical/timeline for a new patient | 200, data.timeline: [], data.facilities: [] |
| T2 | Timeline with records across 2 tenants | 200, data.facilities has 2 entries, data.timeline is merged and sorted by date DESC |
| T3 | Timeline ?limit=5 | data.timeline.length <= 5 |
| T4 | Timeline with a mix of resource types | Contains type: 'encounter', type: 'prescription', type: 'condition', etc. |
6.4 Fan-out budget
| # | Scenario | Expected |
|---|---|---|
| F1 | User with 10 Clinical::Patient rows | 200, service iterates exactly 10 tenants |
| F2 | User with 11+ rows | 200, 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
| # | Scenario | Expected |
|---|---|---|
| SEC1 | List, show, update, delete, restore — every endpoint, with Other's id | 404 for every verb |
| SEC2 | Create a row with user_id set to Other's id in the payload | user_id is ignored — row created with user_id = current_user.id. The API must not accept a client-specified user_id override. |
| SEC3 | recorded_by_user_id set to Other's id in payload | 422, v1 guard rejects |
| SEC4 | Bulk create — verify caller's user_id is stamped on every row | Every created row has user_id = caller regardless of payload |
7.2 Authentication
| # | Scenario | Expected |
|---|---|---|
| SEC5 | No Authorization header | 401 unauthorized for every endpoint |
| SEC6 | Expired / revoked token | 401 |
| SEC7 | Malformed Bearer header (wrong prefix, no token) | 401 |
7.3 Tenant leakage
| # | Scenario | Expected |
|---|---|---|
| SEC8 | Request with an X-Tenant-Slug header set to another tenant | Ignored — tenant context is not set on Patient:: requests |
| SEC9 | Cross-tenant read service raises mid-iteration | Existing 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
| # | Scenario | Expected |
|---|---|---|
| SEC10 | Import the same clinical record twice via source: 'clinical_imported' with identical source_reference | Second attempt: 422 (or DB unique violation surfaced as validation error). Partial unique index enforces idempotency. |
| SEC11 | Bulk sync the same sample_id twice | Second batch: 0 created, N skipped |
8. Error & edge cases
| # | Scenario | Expected |
|---|---|---|
| E1 | Create vital with value that isn't jsonb (e.g. raw number) | 422 or strong params reject |
| E2 | Create with unknown top-level keys | Keys silently dropped by strong params |
| E3 | PATCH with no body | 422 (or 400 depending on Rails config) — no server 500 |
| E4 | Upload a 50 MB document | See Phase 6 follow-up — not yet size-limited at the app layer |
| E5 | Non-JSON Content-Type on POST | Likely 406 or 415 — not 500 |
| E6 | Very long query string (?from=...&to=... with huge values) | 422 on validation, not 500 |
| E7 | Simultaneous bulk posts with overlapping sample_id | DB unique index serializes; no duplicates |
9. Performance expectations
Local development numbers — not production SLOs.
| Operation | Expected latency | Notes |
|---|---|---|
| Patient-reported list (N≤100) | < 50ms | Single-table query |
| Patient-reported create | < 50ms | Single insert |
| Bulk vitals (100 rows) | < 200ms | Single insert_all with ON CONFLICT |
| Clinical list (1 tenant) | < 100ms | One RLS switch |
| Clinical list (5 tenants) | < 300ms | Five RLS switches |
| Timeline (5 tenants × 7 resource types × 50 limit) | < 500ms | P95 target per plan |
| Document create with 2 MB PDF | < 500ms | Attach + 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.sqldiff 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.
| Feature | Status | Expected current behavior |
|---|---|---|
| Explicit patient-to-facility sharing | Deferred | Patient-reported data is visible only to the patient; no share endpoint exists |
| Caregiver / family-member authorship | Deferred | recorded_by_user_id must equal user_id; cross-user authorship rejected |
| Right-to-erasure / GDPR hard-delete | Deferred | FKs are ON DELETE RESTRICT; deleting a user whose rows exist returns a DB error |
| Document file size limits | Deferred to Phase 6 ops work | No app-layer size validation (rely on web server / S3 limits) |
| Virus scanning on uploads | Deferred | Files are stored as-is |
| Pagination cursor for cross-tenant reads | Deferred | Offset-based pagination with 10-tenant fan-out cap; adequate for typical usage |
12. References
- Developer guide: ../developer/patient-domain/README.md
- API reference: ../../api-reference/patient/README.md
- Cross-tenant appointments: ../developer/patient/cross-tenant-appointments.md
- Cross-tenant QA cases: ./patient-cross-tenant-qa.md
- ADR: ../../adrs/2026-04-23-002-patient-domain-architecture.md
- Implementation plan: ../../plans/2026-04-22-feat-patient-domain-backend-implementation-plan.md
- Frontend integration follow-up: ../../plans/2026-04-23-feat-patients-app-patient-api-integration-plan.md