Kulcare Docs
DeveloperCare plansLhr

New Schema Testing Guide

This guide covers how to test the new PostgreSQL multi-schema architecture, including running automated tests, manual API testing, and verifying tenant isolation.


Table of Contents

  1. Prerequisites
  2. Database Setup
  3. Running Automated Tests
  4. Manual API Testing
  5. Rails Console Verification
  6. Tenant Isolation Testing
  7. Application-Level Tenant Context Testing
  8. Background Job Testing
  9. Frontend Manual QA
  10. Troubleshooting

Prerequisites

Before testing, ensure you have:

  • PostgreSQL 14+ running
  • Ruby and Bundler installed
  • Backend dependencies installed (bundle install)
  • Test database created
cd backend

# Install dependencies
bundle install

# Verify PostgreSQL is running
pg_isready

Database Setup

Create Test Database with New Schemas

# Create and migrate test database
RAILS_ENV=test bundle exec rake db:create db:migrate

# Verify schemas were created
RAILS_ENV=test bundle exec rake new_schema:verify
# Seed reference data for meaningful tests
RAILS_ENV=test bundle exec rake new_schema:seed_reference

# Verify seed data
RAILS_ENV=test bundle exec rake new_schema:verify_reference_data

Check Schema Status

# View migration status for new schema migrations
bundle exec rake db:migrate:status | grep 20251231

# Expected output:
#    up     20251231000001  Create new pg schemas
#    up     20251231000002  Create tenant context function
#    up     20251231000003  Add system schema
#    up     20251231000004  Add uuidv7 support
#    up     20251231000005  Create reference foundation tables
#    up     20251231000006  Create identity foundation tables
#    up     20251231000007  Setup identity rls

Running Automated Tests

Test Locations

domains/
  reference/spec/
    rails_helper.rb
    blueprints/           # Blueprint serialization tests
    requests/             # API integration tests (8 controllers)
      countries_spec.rb
      states_spec.rb
      cities_spec.rb
      languages_spec.rb
      currencies_spec.rb
      specialties_spec.rb
      facility_types_spec.rb
      identifier_systems_spec.rb

  identity/spec/
    rails_helper.rb
    blueprints/           # Blueprint serialization tests
    models/               # Model unit tests
    requests/             # API integration tests (7 controllers + isolation)
      tenants_spec.rb
      users_spec.rb
      organizations_spec.rb
      facilities_spec.rb
      practitioners_spec.rb
      practitioner_roles_spec.rb
      healthcare_services_spec.rb
      tenant_isolation_spec.rb

Running All Domain Tests

# Run all Reference domain tests
bundle exec rspec domains/reference/spec/

# Run all Identity domain tests
bundle exec rspec domains/identity/spec/

# Run all domain tests together
bundle exec rspec domains/*/spec/

Running Specific Test Types

# Blueprint tests only
bundle exec rspec domains/reference/spec/blueprints/
bundle exec rspec domains/identity/spec/blueprints/

# Request specs (API integration) only
bundle exec rspec domains/reference/spec/requests/
bundle exec rspec domains/identity/spec/requests/

# Model tests only
bundle exec rspec domains/identity/spec/models/

Running Individual Test Files

# Single file
bundle exec rspec domains/reference/spec/requests/countries_spec.rb

# With verbose output
bundle exec rspec domains/reference/spec/requests/countries_spec.rb -fd

# Specific test by line number
bundle exec rspec domains/identity/spec/requests/facilities_spec.rb:42

Running Tenant Isolation Tests

# Run all RLS verification tests
bundle exec rspec domains/identity/spec/requests/tenant_isolation_spec.rb -fd

# Run specific isolation test
bundle exec rspec domains/identity/spec/requests/tenant_isolation_spec.rb \
  -e "Organization Isolation"

Test Output Options

# Documentation format (verbose)
bundle exec rspec domains/*/spec/requests/ -fd

# Show only failures
bundle exec rspec domains/*/spec/requests/ --only-failures

# Dry run (list tests without running)
bundle exec rspec domains/*/spec/requests/ --dry-run

# With timing info
bundle exec rspec domains/*/spec/requests/ --profile

Manual API Testing

Start the Development Server

cd backend
rails server

Reference API Endpoints (No Authentication Required)

# List all countries
curl -s http://localhost:3000/reference/countries | jq

# Get a specific country
curl -s http://localhost:3000/reference/countries/<UUID> | jq

# Search countries
curl -s "http://localhost:3000/reference/countries?q=India" | jq

# List states for a country
curl -s "http://localhost:3000/reference/states?country_id=<UUID>" | jq

# List cities with filters
curl -s "http://localhost:3000/reference/cities?tier=metro" | jq
curl -s "http://localhost:3000/reference/cities?state_id=<UUID>" | jq

# List languages
curl -s http://localhost:3000/reference/languages | jq

# List currencies
curl -s http://localhost:3000/reference/currencies | jq

# List specialties with filters
curl -s http://localhost:3000/reference/specialties | jq
curl -s "http://localhost:3000/reference/specialties?major=true" | jq
curl -s "http://localhost:3000/reference/specialties?surgical=true" | jq

# List facility types
curl -s http://localhost:3000/reference/facility_types | jq

# List identifier systems
curl -s "http://localhost:3000/reference/identifier_systems?country_id=<UUID>" | jq

Expected Response Format

All endpoints return responses in this format (snake_case keys):

{
  "success": true,
  "data": {
    "id": "019b7d57-2257-7b1e-bf8c-d08ffa8f5081",
    "name": "India",
    "alpha_2_code": "IN",
    "alpha_3_code": "IND",
    "phone_code": "+91",
    "active": true
  }
}

For collections with pagination:

{
  "success": true,
  "data": [...],
  "meta": {
    "pagination": {
      "total_entries": 25,
      "total_pages": 3,
      "current_page": 1,
      "per_page": 10
    }
  }
}

Pagination Parameters

# Page 2 with 5 items per page
curl -s "http://localhost:3000/reference/countries?page=2&per_page=5" | jq

Identity API Endpoints (Requires Authentication)

Identity endpoints require a valid JWT token with tenant context. For manual testing, you'll need to:

  1. Obtain a JWT token through login
  2. Include the token in the Authorization header
# Example with JWT token
TOKEN="your-jwt-token-here"

curl -s http://localhost:3000/identity/tenant \
  -H "Authorization: Bearer $TOKEN" | jq

curl -s http://localhost:3000/identity/facilities \
  -H "Authorization: Bearer $TOKEN" | jq

curl -s http://localhost:3000/identity/practitioners \
  -H "Authorization: Bearer $TOKEN" | jq

Rails Console Verification

Quick Schema Verification

bundle exec rails console
# Check if tables exist
puts "Reference Tables:"
puts "  Countries: #{Reference::Country.table_exists?}"
puts "  States: #{Reference::State.table_exists?}"
puts "  Cities: #{Reference::City.table_exists?}"
puts "  Languages: #{Reference::Language.table_exists?}"
puts "  Currencies: #{Reference::Currency.table_exists?}"
puts "  Specialties: #{Reference::Specialty.table_exists?}"
puts "  FacilityTypes: #{Reference::FacilityType.table_exists?}"
puts "  IdentifierSystems: #{Reference::IdentifierSystem.table_exists?}"

puts "\nIdentity Tables:"
puts "  Tenants: #{Identity::Tenant.table_exists?}"
puts "  Users: #{Identity::User.table_exists?}"
puts "  UserMemberships: #{Identity::UserMembership.table_exists?}"
puts "  Organizations: #{Identity::Organization.table_exists?}"
puts "  Facilities: #{Identity::Facility.table_exists?}"
puts "  Practitioners: #{Identity::Practitioner.table_exists?}"
puts "  PractitionerRoles: #{Identity::PractitionerRole.table_exists?}"

Test Reference Data

# Count records
Reference::Country.count
Reference::Specialty.count

# Query specific records
Reference::Country.find_by(alpha_2_code: 'IN')
Reference::Specialty.major.active.count

# Test associations
india = Reference::Country.find_by(alpha_2_code: 'IN')
india.states.count
india.states.first.cities.count

Test Identity Models

# Create a test tenant
tenant = Identity::Tenant.create!(
  name: 'Test Clinic',
  slug: 'test-clinic',
  status: 'active',
  plan_tier: 'professional'
)

# Create an organization
org = Identity::Organization.create!(
  tenant: tenant,
  name: 'Test Organization',
  organization_type: 'clinic_group',
  active: true
)

# Create a facility
facility = Identity::Facility.create!(
  tenant: tenant,
  organization: org,
  name: 'Main Clinic',
  service_level: 'primary',
  has_opd: true,
  active: true
)

# Create a practitioner (cross-tenant)
practitioner = Identity::Practitioner.create!(
  first_name: 'John',
  last_name: 'Doe',
  display_name: 'Dr. John Doe',
  active: true
)

# Create a practitioner role (tenant-scoped)
role = Identity::PractitionerRole.create!(
  tenant: tenant,
  practitioner: practitioner,
  facility: facility,
  role_type: 'physician',
  status: 'active'
)

# Verify associations
tenant.organizations.count  # => 1
tenant.facilities.count     # => 1
facility.practitioner_roles.count  # => 1

Test Blueprinter Serialization

# Test blueprint rendering
country = Reference::Country.first
puts Reference::CountryBlueprint.render(country, view: :normal)
puts Reference::CountryBlueprint.render(country, view: :detailed)

# Test Identity blueprint
facility = Identity::Facility.first
puts Identity::FacilityBlueprint.render(facility, view: :normal)
puts Identity::FacilityBlueprint.render(facility, view: :detailed)

Tenant Isolation Testing

Manual RLS Verification

# In Rails console

# Create two tenants
tenant_a = Identity::Tenant.create!(name: 'Tenant A', slug: 'tenant-a', status: 'active', plan_tier: 'free')
tenant_b = Identity::Tenant.create!(name: 'Tenant B', slug: 'tenant-b', status: 'active', plan_tier: 'free')

# Create organizations in each tenant
org_a = Identity::Organization.create!(tenant: tenant_a, name: 'Org A', organization_type: 'clinic_group')
org_b = Identity::Organization.create!(tenant: tenant_b, name: 'Org B', organization_type: 'clinic_group')

# Without tenant context, both are visible (via ActiveRecord)
Identity::Organization.count  # => 2

# Set tenant context for Tenant A
ActiveRecord::Base.connection.execute("SELECT set_tenant_context('#{tenant_a.id}')")

# With RLS enabled, only Tenant A's data should be visible
# (This requires RLS to be enforced on the connection)

# Clear tenant context
ActiveRecord::Base.connection.execute("SELECT clear_tenant_context()")

Automated Tenant Isolation Tests

The tenant_isolation_spec.rb file tests these scenarios:

  1. Organization Isolation

    • Tenant A can only see their organizations
    • Tenant A cannot access Tenant B's organizations
    • Tenant A cannot modify/delete Tenant B's organizations
  2. Facility Isolation

    • Facilities are scoped to their tenant
    • Cross-tenant facility access returns 404
  3. Practitioner Isolation

    • Practitioners are visible through their roles in the tenant
    • Cross-tenant practitioner access returns 404
  4. PractitionerRole Isolation

    • Role assignments are tenant-scoped
    • Cannot modify roles in other tenants
  5. HealthcareService Isolation

    • Services are tenant-scoped through facilities
    • Cross-tenant service access returns 404
  6. Cross-Tenant Creation Prevention

    • New resources always belong to current tenant
    • Cannot specify a different tenant_id
  7. Data Integrity After Tenant Switch

    • Switching tenant context shows different data
    • No data leakage between tenants

Run these tests:

bundle exec rspec domains/identity/spec/requests/tenant_isolation_spec.rb -fd

Application-Level Tenant Context Testing

This section covers testing the application-level tenant context system (Shared::Current) and TenantScoped model behavior.

Testing with Shared::Current

The Shared::Current class provides request-scoped storage. In tests, you need to set this up manually.

RSpec Support Helper

Create a support file for tenant context helpers:

# spec/support/tenant_context.rb

module TenantContextHelpers
  # Set up tenant context for tests
  def set_tenant_context(tenant)
    Shared::Current.tenant = tenant
  end

  # Set up full request context (tenant + user + membership)
  def set_full_context(tenant:, user:, membership: nil)
    Shared::Current.tenant = tenant
    Shared::Current.user = user
    Shared::Current.membership = membership || user.membership_for(tenant)
  end

  # Clear tenant context
  def clear_tenant_context
    Shared::Current.reset
    # Also clear RLS context
    ActiveRecord::Base.connection.execute(
      "SELECT set_config('app.current_tenant_id', '', true)"
    )
  end
end

RSpec.configure do |config|
  config.include TenantContextHelpers

  # Clear context after each test
  config.after(:each) do
    clear_tenant_context
  end
end

Unit Test Examples

# spec/domains/identity/models/organization_spec.rb
require 'rails_helper'

RSpec.describe Identity::Organization do
  let(:tenant) { create(:identity_tenant) }
  let(:other_tenant) { create(:identity_tenant) }

  before { set_tenant_context(tenant) }

  describe 'TenantScoped concern' do
    it 'auto-assigns tenant_id on creation' do
      org = Identity::Organization.create!(
        name: 'Test Org',
        organization_type: 'clinic_group'
        # Note: tenant_id is NOT specified
      )

      expect(org.tenant_id).to eq(tenant.id)
    end

    it 'validates tenant_id presence' do
      Shared::Current.reset  # Clear context

      org = Identity::Organization.new(
        name: 'Test Org',
        organization_type: 'clinic_group'
      )

      expect(org).not_to be_valid
      expect(org.errors[:tenant_id]).to include("can't be blank")
    end

    it 'prevents tenant_id modification' do
      org = create(:identity_organization, tenant: tenant)

      expect {
        org.update!(tenant_id: other_tenant.id)
      }.to raise_error(ActiveRecord::RecordInvalid, /Tenant cannot be changed/)
    end
  end

  describe 'default_scope filtering' do
    let!(:org_a) { create(:identity_organization, tenant: tenant) }
    let!(:org_b) { create(:identity_organization, tenant: other_tenant) }

    it 'only returns records for current tenant' do
      expect(Identity::Organization.all).to include(org_a)
      expect(Identity::Organization.all).not_to include(org_b)
    end

    it 'can bypass default_scope with unscoped_by_tenant' do
      all_orgs = Identity::Organization.unscoped_by_tenant.all
      expect(all_orgs).to include(org_a, org_b)
    end

    it 'can query specific tenant with for_tenant' do
      other_orgs = Identity::Organization.for_tenant(other_tenant.id).all
      expect(other_orgs).to include(org_b)
      expect(other_orgs).not_to include(org_a)
    end
  end
end

Testing Current User and Permissions

# spec/domains/identity/models/user_membership_spec.rb
RSpec.describe Identity::UserMembership do
  let(:tenant) { create(:identity_tenant) }
  let(:user) { create(:identity_user) }

  describe 'Shared::Current helpers' do
    it 'exposes permission checks' do
      membership = create(:identity_user_membership,
        tenant: tenant,
        user: user,
        role: 'admin'
      )

      set_full_context(tenant: tenant, user: user, membership: membership)

      expect(Shared::Current.admin?).to be true
      expect(Shared::Current.owner?).to be false
      expect(Shared::Current.staff?).to be true
    end

    it 'checks specific permissions' do
      membership = create(:identity_user_membership,
        tenant: tenant,
        user: user,
        role: 'staff'
      )

      set_full_context(tenant: tenant, user: user, membership: membership)

      expect(Shared::Current.membership.has_permission?('view_patients')).to be true
      expect(Shared::Current.membership.has_permission?('manage_users')).to be false
    end
  end
end

Request Spec with Tenant Context

# spec/domains/identity/requests/organizations_spec.rb
require 'rails_helper'

RSpec.describe 'Organizations API', type: :request do
  let(:tenant) { create(:identity_tenant) }
  let(:user) { create(:identity_user) }
  let(:membership) { create(:identity_user_membership, tenant: tenant, user: user, role: 'admin') }

  # Mock JWT authentication
  let(:auth_headers) do
    token = generate_jwt_token(user: user, tenant: tenant)
    { 'Authorization' => "Bearer #{token}" }
  end

  describe 'GET /identity/organizations' do
    let!(:my_org) { create(:identity_organization, tenant: tenant) }
    let!(:other_org) { create(:identity_organization) }  # Different tenant

    it 'only returns organizations for authenticated tenant' do
      get '/identity/organizations', headers: auth_headers

      expect(response).to have_http_status(:ok)

      data = JSON.parse(response.body)['data']
      ids = data.map { |o| o['id'] }

      expect(ids).to include(my_org.id)
      expect(ids).not_to include(other_org.id)
    end
  end

  describe 'POST /identity/organizations' do
    it 'auto-assigns tenant_id from context' do
      post '/identity/organizations',
        params: { organization: { name: 'New Org', organization_type: 'clinic_group' } },
        headers: auth_headers

      expect(response).to have_http_status(:created)

      org = Identity::Organization.last
      expect(org.tenant_id).to eq(tenant.id)
    end
  end
end

Testing RLS Context Setting

# spec/domains/shared/current_spec.rb
require 'rails_helper'

RSpec.describe Shared::Current do
  let(:tenant) { create(:identity_tenant) }

  describe '#tenant=' do
    it 'sets RLS context when tenant is assigned' do
      Shared::Current.tenant = tenant

      result = ActiveRecord::Base.connection.execute(
        "SELECT current_setting('app.current_tenant_id', true)"
      )
      expect(result.first['current_setting']).to eq(tenant.id)
    end

    it 'clears RLS context when tenant is nil' do
      Shared::Current.tenant = tenant
      Shared::Current.tenant = nil

      result = ActiveRecord::Base.connection.execute(
        "SELECT current_setting('app.current_tenant_id', true)"
      )
      expect(result.first['current_setting']).to be_empty
    end
  end
end

Background Job Testing

This section covers testing Sidekiq jobs that require tenant context.

Testing Jobs with Automatic Context

The Sidekiq middleware automatically captures and restores tenant context:

# spec/jobs/send_notification_job_spec.rb
require 'rails_helper'

RSpec.describe SendNotificationJob, type: :job do
  include ActiveJob::TestHelper

  let(:tenant) { create(:identity_tenant) }
  let(:user) { create(:identity_user) }
  let(:notification) { create(:notification, tenant: tenant) }

  before { set_tenant_context(tenant) }

  it 'executes within tenant context' do
    # Enqueue from within tenant context
    expect {
      SendNotificationJob.perform_later(notification.id)
    }.to have_enqueued_job(SendNotificationJob)

    # Verify job payload includes tenant_id
    job = enqueued_jobs.last
    expect(job['tenant_id']).to eq(tenant.id)
  end

  it 'restores tenant context when performed' do
    perform_enqueued_jobs do
      SendNotificationJob.perform_later(notification.id)
    end

    # Verify the job executed with correct tenant
    # (Add assertions based on what your job does)
  end
end

Testing TenantAwareJob Concern

For jobs that use the TenantAwareJob concern for explicit tenant control:

# spec/jobs/cross_tenant_report_job_spec.rb
require 'rails_helper'

RSpec.describe CrossTenantReportJob, type: :job do
  include ActiveJob::TestHelper

  let(:tenant_a) { create(:identity_tenant) }
  let(:tenant_b) { create(:identity_tenant) }

  describe '.perform_for_tenant' do
    it 'enqueues with explicit tenant context' do
      # No current tenant context set
      CrossTenantReportJob.perform_for_tenant(tenant_a, report_params)

      job = enqueued_jobs.last
      expect(job['tenant_id']).to eq(tenant_a.id)
    end
  end

  describe '#with_tenant' do
    it 'switches tenant context within job' do
      job = CrossTenantReportJob.new

      # Verify tenant A context
      job.send(:with_tenant, tenant_a) do
        expect(Shared::Current.tenant).to eq(tenant_a)
        expect(Identity::Organization.all.to_sql).to include(tenant_a.id)
      end

      # Verify tenant B context
      job.send(:with_tenant, tenant_b) do
        expect(Shared::Current.tenant).to eq(tenant_b)
        expect(Identity::Organization.all.to_sql).to include(tenant_b.id)
      end
    end

    it 'restores previous context after block' do
      set_tenant_context(tenant_a)
      job = CrossTenantReportJob.new

      job.send(:with_tenant, tenant_b) do
        # Inside block, tenant_b is active
        expect(Shared::Current.tenant).to eq(tenant_b)
      end

      # After block, tenant_a is restored
      expect(Shared::Current.tenant).to eq(tenant_a)
    end
  end

  describe '#each_tenant' do
    let!(:org_a) { create(:identity_organization, tenant: tenant_a) }
    let!(:org_b) { create(:identity_organization, tenant: tenant_b) }

    it 'iterates through all tenants with correct context' do
      job = CrossTenantReportJob.new
      processed_tenants = []

      job.send(:each_tenant) do |tenant|
        processed_tenants << tenant
        # Verify correct organization is visible
        orgs = Identity::Organization.all.to_a
        expect(orgs.map(&:tenant_id).uniq).to eq([tenant.id])
      end

      expect(processed_tenants).to include(tenant_a, tenant_b)
    end

    it 'accepts a scope to limit tenants' do
      active_tenants = Identity::Tenant.where(status: 'active')
      job = CrossTenantReportJob.new
      processed = []

      job.send(:each_tenant, active_tenants) do |tenant|
        processed << tenant
      end

      # Only active tenants are processed
      expect(processed.map(&:status).uniq).to eq(['active'])
    end
  end

  describe '#require_tenant_context!' do
    it 'raises error when no tenant context' do
      clear_tenant_context
      job = CrossTenantReportJob.new

      expect {
        job.send(:require_tenant_context!)
      }.to raise_error(Shared::Errors::TenantRequiredError)
    end

    it 'passes when tenant context is set' do
      set_tenant_context(tenant_a)
      job = CrossTenantReportJob.new

      expect { job.send(:require_tenant_context!) }.not_to raise_error
    end
  end
end

Testing Sidekiq Middleware Directly

# spec/middleware/sidekiq_tenant_middleware_spec.rb
require 'rails_helper'

RSpec.describe 'Sidekiq Tenant Middleware' do
  let(:tenant) { create(:identity_tenant) }
  let(:user) { create(:identity_user) }

  describe Sidekiq::Middleware::TenantContextClient do
    let(:middleware) { Sidekiq::Middleware::TenantContextClient.new }

    it 'captures tenant_id in job payload' do
      set_full_context(tenant: tenant, user: user)

      job = {}
      middleware.call('SomeWorker', job, 'default', nil) { }

      expect(job['tenant_id']).to eq(tenant.id)
      expect(job['user_id']).to eq(user.id)
    end

    it 'handles nil tenant gracefully' do
      clear_tenant_context

      job = {}
      middleware.call('SomeWorker', job, 'default', nil) { }

      expect(job['tenant_id']).to be_nil
      expect(job['user_id']).to be_nil
    end
  end

  describe Sidekiq::Middleware::TenantContextServer do
    let(:middleware) { Sidekiq::Middleware::TenantContextServer.new }

    it 'restores tenant context from job payload' do
      job = {
        'tenant_id' => tenant.id,
        'user_id' => user.id
      }

      middleware.call(nil, job, 'default') do
        expect(Shared::Current.tenant).to eq(tenant)
        expect(Shared::Current.user).to eq(user)
      end
    end

    it 'clears context after job completes' do
      job = { 'tenant_id' => tenant.id }

      middleware.call(nil, job, 'default') { }

      expect(Shared::Current.tenant).to be_nil
      result = ActiveRecord::Base.connection.execute(
        "SELECT current_setting('app.current_tenant_id', true)"
      )
      expect(result.first['current_setting']).to be_empty
    end

    it 'clears context even if job raises' do
      job = { 'tenant_id' => tenant.id }

      expect {
        middleware.call(nil, job, 'default') { raise StandardError }
      }.to raise_error(StandardError)

      expect(Shared::Current.tenant).to be_nil
    end
  end
end

Integration Test for Full Job Lifecycle

# spec/jobs/integration/tenant_job_lifecycle_spec.rb
require 'rails_helper'
require 'sidekiq/testing'

RSpec.describe 'Tenant Job Lifecycle', type: :job do
  let(:tenant) { create(:identity_tenant) }
  let!(:org) { create(:identity_organization, tenant: tenant) }

  around do |example|
    Sidekiq::Testing.inline! { example.run }
  end

  it 'maintains tenant context through full job lifecycle' do
    # Set up tenant context (simulating a request)
    set_tenant_context(tenant)

    # Enqueue a job that queries tenant-scoped data
    result = nil
    TestTenantJob.perform_later do
      result = Identity::Organization.all.to_a
    end

    # Verify the job saw the correct data
    expect(result).to eq([org])
  end
end

# Test job class
class TestTenantJob < ApplicationJob
  def perform(&block)
    block.call if block_given?
  end
end

Factory Setup for Tenant Testing

# spec/factories/identity_tenants.rb
FactoryBot.define do
  factory :identity_tenant, class: 'Identity::Tenant' do
    sequence(:name) { |n| "Tenant #{n}" }
    sequence(:slug) { |n| "tenant-#{n}" }
    status { 'active' }
    plan_tier { 'professional' }
  end

  factory :identity_user, class: 'Identity::User' do
    sequence(:email) { |n| "user#{n}@example.com" }
    sequence(:phone_number) { |n| "+1555000#{n.to_s.rjust(4, '0')}" }
    primary_identifier_type { 'email' }
    status { 'active' }
  end

  factory :identity_user_membership, class: 'Identity::UserMembership' do
    association :tenant, factory: :identity_tenant
    association :user, factory: :identity_user
    role { 'staff' }
    status { 'active' }
  end

  factory :identity_organization, class: 'Identity::Organization' do
    association :tenant, factory: :identity_tenant
    sequence(:name) { |n| "Organization #{n}" }
    organization_type { 'clinic_group' }
    active { true }
  end
end

Frontend Manual QA

This section covers manual testing for the CMS frontend encounter module, particularly verifying that the query gating optimization works correctly.

Encounter Query Gating Verification

The useEncounterClinicalData hook gates specialty-specific API calls based on which tabs are active for the current practitioner type. This prevents unnecessary API calls (e.g., a general doctor encounter should not fire dental, cardiology, or pediatric queries).

What to Test

Open encounters for different practitioner types and verify that only relevant API calls fire in the browser Network tab.

Setup

  1. Start the backend: cd backend && rails server
  2. Start the frontend: cd frontend && pnpm dev:cms
  3. Open Chrome DevTools > Network tab
  4. Filter by observations and service_requests to isolate clinical API calls

Test Cases

General Doctor (expect ~6 API calls)

  1. Open an encounter for a general practitioner
  2. In the Network tab, verify only these queries fire:
    • observations?encounter_id=...&vital_signs=true (vitals)
    • observations?encounter_id=...&category=exam (notes)
    • medication_requests?encounter_id=... (medications)
    • service_requests?encounter_id=... (investigations)
    • observations?encounter_id=...&category=advice (advice)
    • observations?encounter_id=...&category=followup (follow-up)
  3. Verify NO specialty queries fire (no dental_examination, cardiac_examination, pediatric_examination, etc.)

Dentist (expect ~8 API calls)

  1. Open an encounter for a dentist
  2. Verify the 6 core queries fire (same as general doctor)
  3. Verify dental-specific queries fire:
    • observations?encounter_id=...&category=dental_examination
    • service_requests?encounter_id=...&category=dental_procedure
  4. Verify NO non-dental specialty queries fire

Pediatrician (expect ~10 API calls)

  1. Open an encounter for a pediatrician
  2. Verify the 6 core queries fire
  3. Verify pediatric-specific queries fire:
    • observations?encounter_id=...&category=pediatric_examination
    • observations?encounter_id=...&category=immunization_record
    • observations?encounter_id=...&category=growth_assessment
    • service_requests?encounter_id=...&category=pediatric_procedure
  4. Verify NO non-pediatric specialty queries fire

Cardiologist (expect ~10 API calls)

  1. Open an encounter for a cardiologist
  2. Verify the 6 core queries fire
  3. Verify cardiology-specific queries fire:
    • observations?encounter_id=...&category=cardiac_examination
    • observations?encounter_id=...&category=ecg_interpretation
    • observations?encounter_id=...&category=cardiac_risk_assessment
    • service_requests?encounter_id=...&category=cardiac_procedure
  4. Verify NO non-cardiology specialty queries fire

Expected Behavior Summary

Practitioner TypeExpected API CallsShould NOT Fire
General Doctor~6 (core only)All specialty queries
Dentist~8 (core + dental)All non-dental specialties
Physiotherapist~10 (core + physio + history)All non-physio specialties
Dermatologist~9 (core + derm)All non-derm specialties
Cardiologist~10 (core + cardiac)All non-cardiac specialties
Pediatrician~10 (core + pediatric)All non-pediatric specialties
Gynecologist~9 (core + gynec)All non-gynec specialties

Regression Checks

After verifying reduced API calls, confirm that:

  1. Data loads correctly: Switch between tabs within the encounter -- data should display properly on each tab
  2. Saving works: Make changes on a specialty tab and save -- verify the save completes without errors
  3. No missing data: Open an existing encounter with previously saved specialty data -- verify it loads correctly
  4. Tab switching: Navigate between tabs and confirm no additional unexpected API calls fire
  5. TypeScript: Run pnpm typecheck --filter=@kulcare/cms to confirm no type errors

Troubleshooting

Tests Fail with 500 Errors

This usually means the database schemas don't exist:

# Check if migrations have run
bundle exec rake db:migrate:status | grep 20251231

# Run migrations for test environment
RAILS_ENV=test bundle exec rake db:migrate

# Or reset test database completely
RAILS_ENV=test bundle exec rake db:drop db:create db:migrate

Tests Skip with "Schema not available"

This is expected behavior when schemas don't exist. The specs include guards:

before do
  skip 'Identity schema not available' unless Identity::Tenant.table_exists?
end

To fix, run migrations as shown above.

"Table does not exist" Errors

# Verify table exists
bundle exec rails runner "puts Identity::Tenant.table_exists?"

# Check schema search path
bundle exec rails runner "puts ActiveRecord::Base.connection.schema_search_path"

UUIDv7 Function Not Found

# Verify UUIDv7 function exists
bundle exec rails runner "
  result = ActiveRecord::Base.connection.execute('SELECT generate_uuidv7()')
  puts result.first['generate_uuidv7']
"

If it fails, run the UUIDv7 migration:

bundle exec rake db:migrate VERSION=20251231000004

RLS Not Working

# Verify RLS is enabled on a table
bundle exec rails runner "
  sql = \"SELECT relname, relrowsecurity, relforcerowsecurity
         FROM pg_class
         WHERE relname = 'facilities'
         AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'identity')\"
  result = ActiveRecord::Base.connection.execute(sql)
  puts result.to_a
"

Tenant Context Not Set

# In Rails console, check current tenant context
result = ActiveRecord::Base.connection.execute("SELECT current_tenant_id()")
puts result.first['current_tenant_id']

# Set tenant context
ActiveRecord::Base.connection.execute("SELECT set_tenant_context('your-tenant-uuid')")

# Clear tenant context
ActiveRecord::Base.connection.execute("SELECT clear_tenant_context()")

Blueprint snake_case Not Working

Verify the transformer is configured:

# Check Blueprinter configuration
puts Blueprinter.configuration.inspect

# Test transformation manually
country = Reference::Country.first
json = Reference::CountryBlueprint.render_as_hash(country, view: :normal)
puts json.keys  # Should be snake_case: ["id", "name", "alpha_2_code", ...]

Shared::Current Not Set in Tests

If Shared::Current.tenant is nil in tests:

# Verify the support helper is loaded
# spec/support/tenant_context.rb should be required

# In spec/rails_helper.rb or spec/spec_helper.rb:
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }

# Or explicitly set context in test:
before do
  Shared::Current.tenant = create(:identity_tenant)
end

# Always clear in after hook to prevent leakage:
after do
  Shared::Current.reset
end

TenantScoped Model Returns Empty Results

If tenant-scoped queries return empty when data exists:

# Check if tenant context is set
puts Shared::Current.tenant.inspect  # Should show tenant, not nil

# Check the RLS context
result = ActiveRecord::Base.connection.execute(
  "SELECT current_setting('app.current_tenant_id', true)"
)
puts result.first['current_setting']  # Should be tenant UUID

# If empty, set it manually:
set_tenant_context(tenant)

# Or bypass default_scope to verify data exists:
Identity::Organization.unscoped_by_tenant.count

Sidekiq Jobs Missing Tenant Context

If background jobs fail with tenant-related errors:

# Verify Sidekiq middleware is loaded
# Check config/initializers/sidekiq_tenant_middleware.rb exists

# In development, check job payload:
Sidekiq::Testing.fake!
MyJob.perform_later(args)
job = Sidekiq::Worker.jobs.last
puts job['tenant_id']  # Should have tenant UUID

# If nil, ensure context is set when enqueueing:
Shared::Current.tenant = tenant
MyJob.perform_later(args)

# Or use TenantAwareJob for explicit context:
MyJob.perform_for_tenant(tenant, args)

Context Leaking Between Tests

If tests affect each other due to context not being cleared:

# Ensure after hook clears context
RSpec.configure do |config|
  config.after(:each) do
    Shared::Current.reset
    ActiveRecord::Base.connection.execute(
      "SELECT set_config('app.current_tenant_id', '', true)"
    )
  end
end

# Use database cleaner with transaction strategy:
RSpec.configure do |config|
  config.use_transactional_fixtures = true
end

TenantRequiredError in Jobs

If jobs raise TenantRequiredError:

# Option 1: Ensure job is enqueued with context
Shared::Current.tenant = tenant
MyJob.perform_later(args)

# Option 2: Use TenantAwareJob concern
class MyJob < ApplicationJob
  include Shared::Concerns::TenantAwareJob

  def perform(tenant_id, args)
    with_tenant(tenant_id) do
      # Job logic here
    end
  end
end

# Option 3: Make job context-optional
def perform(args)
  return unless tenant_context?  # Skip if no context
  # Job logic
end

Quick Reference

Rake Tasks

bundle exec rake new_schema:verify              # Verify all schemas
bundle exec rake new_schema:verify_schemas      # Verify schema existence
bundle exec rake new_schema:verify_functions    # Verify tenant context functions
bundle exec rake new_schema:verify_rls          # Verify RLS policies
bundle exec rake new_schema:seed_reference      # Seed reference data
bundle exec rake new_schema:verify_reference_data  # Verify seed data counts
bundle exec rake new_schema:status              # Show current status

Test Commands

# All tests
bundle exec rspec domains/*/spec/

# Reference only
bundle exec rspec domains/reference/spec/

# Identity only
bundle exec rspec domains/identity/spec/

# Request specs only
bundle exec rspec domains/*/spec/requests/

# Model specs only
bundle exec rspec domains/*/spec/models/

# Tenant isolation only
bundle exec rspec domains/identity/spec/requests/tenant_isolation_spec.rb

# Tenant context tests
bundle exec rspec spec/domains/shared/current_spec.rb
bundle exec rspec spec/middleware/

# Background job tests
bundle exec rspec spec/jobs/

# Verbose output
bundle exec rspec domains/*/spec/requests/ -fd

Tenant Context Helpers

# Set tenant context
set_tenant_context(tenant)

# Set full context (tenant + user + membership)
set_full_context(tenant: tenant, user: user, membership: membership)

# Clear context
clear_tenant_context

# Bypass default scope
Identity::Organization.unscoped_by_tenant.all

# Query specific tenant
Identity::Organization.for_tenant(tenant_id).all

API Endpoints Quick Reference

DomainEndpointAuth Required
Reference/reference/countriesNo
Reference/reference/statesNo
Reference/reference/citiesNo
Reference/reference/languagesNo
Reference/reference/currenciesNo
Reference/reference/specialtiesNo
Reference/reference/facility_typesNo
Reference/reference/identifier_systemsNo
Identity/identity/tenantYes
Identity/identity/tenantsYes (Admin)
Identity/identity/usersYes
Identity/identity/organizationsYes
Identity/identity/facilitiesYes
Identity/identity/practitionersYes
Identity/identity/practitioner_rolesYes
Identity/identity/healthcare_servicesYes

On this page

Table of ContentsPrerequisitesDatabase SetupCreate Test Database with New SchemasSeed Reference Data (Optional but Recommended)Check Schema StatusRunning Automated TestsTest LocationsRunning All Domain TestsRunning Specific Test TypesRunning Individual Test FilesRunning Tenant Isolation TestsTest Output OptionsManual API TestingStart the Development ServerReference API Endpoints (No Authentication Required)Expected Response FormatPagination ParametersIdentity API Endpoints (Requires Authentication)Rails Console VerificationQuick Schema VerificationTest Reference DataTest Identity ModelsTest Blueprinter SerializationTenant Isolation TestingManual RLS VerificationAutomated Tenant Isolation TestsApplication-Level Tenant Context TestingTesting with Shared::CurrentRSpec Support HelperUnit Test ExamplesTesting Current User and PermissionsRequest Spec with Tenant ContextTesting RLS Context SettingBackground Job TestingTesting Jobs with Automatic ContextTesting TenantAwareJob ConcernTesting Sidekiq Middleware DirectlyIntegration Test for Full Job LifecycleFactory Setup for Tenant TestingFrontend Manual QAEncounter Query Gating VerificationWhat to TestSetupTest CasesExpected Behavior SummaryRegression ChecksTroubleshootingTests Fail with 500 ErrorsTests Skip with "Schema not available""Table does not exist" ErrorsUUIDv7 Function Not FoundRLS Not WorkingTenant Context Not SetBlueprint snake_case Not WorkingShared::Current Not Set in TestsTenantScoped Model Returns Empty ResultsSidekiq Jobs Missing Tenant ContextContext Leaking Between TestsTenantRequiredError in JobsQuick ReferenceRake TasksTest CommandsTenant Context HelpersAPI Endpoints Quick Reference