Skip to content

ADR-003: EMR Abstraction Layer

Architecture Decision Record for Multi-EMR Support

Status: Accepted Date: January 14, 2026 Epic: 1.2 Implementation: Complete (Phase 1 + Phase 2)


Context

VitaraVox currently has tight coupling to OSCAR EMR through: - Hardcoded endpoint paths (/ws/rs/*, /demographics) - OSCAR-specific field names (demographicNo, providerNo) - OAuth 1.0a credential structure in database schema - Availability detection assumptions (empty demographicNo = available) - Single-char appointment type codes

To support multiple EMR systems (Oscar Universal, Oscar Pro, Telus Health, Juno), we need an abstraction layer.


Decision

Implement the EMR Adapter Pattern with a unified interface that all EMR integrations must implement.

Architecture Overview (Phase 2 Complete)

┌─────────────────────────────────────────────────────────────────────────────────┐
│                         VOICE AGENT (vapiEndpoints.js)                           │
│                                                                                  │
│   search_patient  find_appointment  book_appointment  get_providers             │
└─────────────────────────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│                         EMR SERVICE LAYER (Phase 2)                              │
│                         services/EmrService.js                                   │
│                                                                                  │
│   ┌────────────────────────────────────────────────────────────────────────┐    │
│   │  FEATURE FLAGS                                                          │    │
│   │  ┌────────────────────┐  ┌────────────────────────────────────────┐    │    │
│   │  │ USE_EMR_ADAPTER    │  │ EMR_ADAPTER_CLINICS                    │    │    │
│   │  │ (global toggle)    │  │ (per-clinic rollout)                   │    │    │
│   │  └────────────────────┘  └────────────────────────────────────────┘    │    │
│   └────────────────────────────────────────────────────────────────────────┘    │
│                                                                                  │
│   ┌────────────────────┐          ┌────────────────────────────────────┐        │
│   │  Legacy Fallback   │◄────OR───│  EmrAdapterFactory                 │        │
│   │  oscarRestService  │          │  (Clinic Config → Adapter)         │        │
│   │  (if flag=false)   │          │                                    │        │
│   └────────────────────┘          └────────────────┬───────────────────┘        │
│                                                    │                             │
└────────────────────────────────────────────────────┼─────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│                         EMR ADAPTER INTERFACE (Phase 1)                          │
│                         adapters/IEmrAdapter.js                                  │
│                                                                                  │
│   IEmrAdapter {                                                                  │
│     searchPatient(query)      getAvailability(providerId, date)                 │
│     getPatient(id)            getAppointments(filter)                           │
│     createPatient(data)       createAppointment(data)                           │
│     getProviders()            updateAppointment(id, data)                       │
│     getProvider(id)           cancelAppointment(id)                             │
│     healthCheck()             getCircuitStatus()                                │
│   }                                                                              │
└─────────────────────────────────────┬────────────────────────────────────────────┘
           ┌──────────────────────────┼──────────────────────────┐
           │                          │                          │
           ▼                          ▼                          ▼
┌─────────────────────┐  ┌─────────────────────┐  ┌─────────────────────┐
│ OscarUniversalAdapter│  │  OscarProAdapter    │  │  TelusHealthAdapter │
│ ✅ IMPLEMENTED      │  │  📋 PLANNED         │  │  📋 PLANNED         │
│                     │  │                     │  │                     │
│ - REST Bridge       │  │  - OAuth 1.0a       │  │  - FHIR R4          │
│ - API Key auth      │  │  - Direct SOAP/REST │  │  - OAuth 2.0        │
│ - Circuit breaker   │  │  - SOAP for schedule│  │  - Native schedule  │
│ - 5-min cache       │  │                     │  │                     │
└─────────────────────┘  └─────────────────────┘  └─────────────────────┘
           │                          │                          │
           │                    TRANSFORMERS                     │
           │          ┌────────────────────────────────┐        │
           │          │  PatientTransformer            │        │
           │          │  ProviderTransformer           │        │
           │          │  AppointmentTransformer        │        │
           │          │  (EMR ↔ Canonical format)      │        │
           │          └────────────────────────────────┘        │
           │                          │                          │
           ▼                          ▼                          ▼
┌─────────────────────┐  ┌─────────────────────┐  ┌─────────────────────┐
│  OSCAR Dev Instance │  │  OSCAR Pro Instance │  │  Telus Health EMR   │
│  (REST-SOAP Bridge) │  │  (Native API)       │  │  (FHIR API)         │
└─────────────────────┘  └─────────────────────┘  └─────────────────────┘

EMR Adapter Interface

TypeScript Interface Definition

// adapters/IEmrAdapter.ts

export interface PatientSearchQuery {
  phone?: string;
  lastName?: string;
  firstName?: string;
  healthCardNumber?: string;
  dateOfBirth?: string;
}

export interface Patient {
  id: string;                    // EMR-agnostic ID (was demographicNo)
  firstName: string;
  lastName: string;
  dateOfBirth: string;           // YYYY-MM-DD
  gender?: 'M' | 'F' | 'O';
  phone?: string;
  email?: string;
  address?: {
    street: string;
    city: string;
    province: string;
    postalCode: string;
    country: string;
  };
  healthCardNumber?: string;
  healthCardProvince?: string;
}

export interface Provider {
  id: string;                    // EMR-agnostic ID (was providerNo)
  firstName: string;
  lastName: string;
  displayName: string;           // "Dr. Sarah Anderson"
  specialty?: string;
  acceptingNewPatients: boolean;
}

export interface TimeSlot {
  startTime: string;             // ISO 8601: 2026-01-14T09:00:00
  endTime: string;               // ISO 8601: 2026-01-14T09:15:00
  duration: number;              // Minutes
  status: 'available' | 'booked' | 'blocked';
  appointmentType?: string;      // EMR-agnostic type code
  description?: string;          // Human-readable type name
}

export interface Availability {
  providerId: string;
  date: string;                  // YYYY-MM-DD
  holiday: boolean;
  timeSlots: TimeSlot[];
}

export interface Appointment {
  id: string;                    // EMR-agnostic ID (was appointmentNo)
  patientId: string;
  providerId: string;
  startTime: string;             // ISO 8601
  endTime: string;               // ISO 8601
  duration: number;
  status: 'scheduled' | 'confirmed' | 'cancelled' | 'completed' | 'no_show';
  reason?: string;
  notes?: string;
  appointmentType?: string;
}

export interface AppointmentCreateRequest {
  patientId: string;
  providerId: string;
  startTime: string;             // ISO 8601
  duration: number;
  reason?: string;
  appointmentType?: string;
  notes?: string;
}

export interface EmrHealthStatus {
  status: 'healthy' | 'degraded' | 'unhealthy';
  latencyMs?: number;
  services?: Record<string, 'connected' | 'disconnected'>;
  version?: string;
}

export interface IEmrAdapter {
  // Metadata
  readonly emrType: string;      // 'oscar-universal' | 'oscar-pro' | 'telus-health'
  readonly version: string;

  // Patient Operations
  searchPatient(query: PatientSearchQuery): Promise<Patient[]>;
  getPatient(id: string): Promise<Patient | null>;
  createPatient(data: Omit<Patient, 'id'>): Promise<Patient>;

  // Provider Operations
  getProviders(): Promise<Provider[]>;
  getProvider(id: string): Promise<Provider | null>;

  // Schedule Operations
  getAvailability(providerId: string, date: string): Promise<Availability>;
  getAppointmentTypes(): Promise<{ code: string; name: string; duration: number }[]>;

  // Appointment Operations
  getAppointments(filter: {
    patientId?: string;
    providerId?: string;
    startDate?: string;
    endDate?: string;
  }): Promise<Appointment[]>;
  createAppointment(data: AppointmentCreateRequest): Promise<Appointment>;
  updateAppointment(id: string, data: Partial<Appointment>): Promise<Appointment>;
  cancelAppointment(id: string): Promise<boolean>;

  // Health Check
  healthCheck(): Promise<EmrHealthStatus>;
}

Impact to Current Architecture

Files Requiring Modification

File Current State Required Changes Risk
vapiEndpoints.js Direct oscarRestService calls Use adapter factory, generic field names High - 14 endpoints
oscarRestService.js REST Bridge client Refactor into OscarUniversalAdapter Medium
oscarService.js Legacy OAuth client Refactor into OscarProAdapter Medium
clinicRouter.js Loads OSCAR credentials Load EMR type + credentials Medium
vitaraDb.js OSCAR-specific schema Add emr_type column Low
utils.js OSCAR-specific formatting EMR-agnostic formatters Low

Database Schema Changes

-- Add EMR type to clinics table
ALTER TABLE clinics ADD COLUMN emr_type VARCHAR(50) DEFAULT 'oscar-universal';
ALTER TABLE clinics ADD COLUMN emr_config JSONB DEFAULT '{}';

-- emr_config structure varies by type:
-- oscar-universal: { "bridgeUrl": "...", "apiKey": "..." }
-- oscar-pro: { "baseUrl": "...", "oauthConsumerKey": "...", ... }
-- telus-health: { "fhirEndpoint": "...", "clientId": "...", "clientSecret": "..." }

New Files to Create

/opt/vitara-platform/voice-agent/
├── adapters/
│   ├── IEmrAdapter.js           # Interface definition
│   ├── EmrAdapterFactory.js     # Factory to create adapters
│   ├── OscarUniversalAdapter.js # Current REST Bridge
│   ├── OscarProAdapter.js       # OAuth 1.0a direct
│   └── BaseEmrAdapter.js        # Common functionality
├── transformers/
│   ├── PatientTransformer.js    # EMR → Canonical patient
│   ├── AppointmentTransformer.js
│   └── ProviderTransformer.js
└── __tests__/
    ├── adapters/
    │   ├── OscarUniversalAdapter.test.js
    │   └── EmrAdapterFactory.test.js
    └── transformers/
        └── PatientTransformer.test.js

Implementation Phases

Phase 1: Interface & Factory (Week 1)

  1. Create IEmrAdapter interface
  2. Create EmrAdapterFactory
  3. Create BaseEmrAdapter with common functionality
  4. Wrap current oscarRestService.js as OscarUniversalAdapter

Phase 2: Refactor Voice Agent (Week 2)

  1. Update clinicRouter.js to load EMR type
  2. Refactor vapiEndpoints.js to use adapter factory
  3. Update database schema
  4. Update field names throughout

Phase 3: Testing & Documentation (Week 3)

  1. Unit tests for all adapters
  2. Integration tests with mock EMR
  3. E2E tests with real OSCAR instance
  4. Documentation updates

Test Plan

Unit Tests

1. EmrAdapterFactory Tests

// __tests__/adapters/EmrAdapterFactory.test.js

describe('EmrAdapterFactory', () => {
  describe('createAdapter()', () => {
    it('should create OscarUniversalAdapter for oscar-universal type', () => {
      const config = { emrType: 'oscar-universal', bridgeUrl: '...', apiKey: '...' };
      const adapter = EmrAdapterFactory.createAdapter(config);
      expect(adapter.emrType).toBe('oscar-universal');
    });

    it('should create OscarProAdapter for oscar-pro type', () => {
      const config = { emrType: 'oscar-pro', baseUrl: '...', oauth: {...} };
      const adapter = EmrAdapterFactory.createAdapter(config);
      expect(adapter.emrType).toBe('oscar-pro');
    });

    it('should throw for unknown EMR type', () => {
      const config = { emrType: 'unknown-emr' };
      expect(() => EmrAdapterFactory.createAdapter(config)).toThrow('Unsupported EMR type');
    });

    it('should validate required config fields', () => {
      const config = { emrType: 'oscar-universal' }; // Missing bridgeUrl
      expect(() => EmrAdapterFactory.createAdapter(config)).toThrow('bridgeUrl is required');
    });
  });
});

2. OscarUniversalAdapter Tests

// __tests__/adapters/OscarUniversalAdapter.test.js

describe('OscarUniversalAdapter', () => {
  let adapter;
  let mockAxios;

  beforeEach(() => {
    mockAxios = { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() };
    adapter = new OscarUniversalAdapter({
      bridgeUrl: 'http://localhost:3000/api/v1',
      apiKey: 'test-key'
    });
    adapter.client = mockAxios;
  });

  describe('searchPatient()', () => {
    it('should search by phone number', async () => {
      mockAxios.get.mockResolvedValue({
        data: { success: true, data: [{ demographicNo: 78, firstName: 'John', lastName: 'Doe' }] }
      });

      const result = await adapter.searchPatient({ phone: '6045551234' });

      expect(mockAxios.get).toHaveBeenCalledWith('/demographics/search', {
        params: { phone: '6045551234' }
      });
      expect(result).toHaveLength(1);
      expect(result[0].id).toBe('78'); // Transformed from demographicNo
    });

    it('should search by name', async () => {
      mockAxios.get.mockResolvedValue({
        data: { success: true, data: [{ demographicNo: 79, firstName: 'Jane', lastName: 'Smith' }] }
      });

      const result = await adapter.searchPatient({ lastName: 'Smith', firstName: 'Jane' });

      expect(mockAxios.get).toHaveBeenCalledWith('/demographics/search', {
        params: { name: 'Smith', firstName: 'Jane' }
      });
    });

    it('should return empty array when no patients found', async () => {
      mockAxios.get.mockResolvedValue({ data: { success: true, data: [] } });

      const result = await adapter.searchPatient({ phone: '0000000000' });

      expect(result).toEqual([]);
    });

    it('should handle API errors gracefully', async () => {
      mockAxios.get.mockRejectedValue(new Error('Connection refused'));

      await expect(adapter.searchPatient({ phone: '6045551234' }))
        .rejects.toThrow('EMR connection failed');
    });
  });

  describe('getPatient()', () => {
    it('should retrieve patient by ID', async () => {
      mockAxios.get.mockResolvedValue({
        data: { success: true, data: { demographicNo: 78, firstName: 'John', lastName: 'Doe' } }
      });

      const result = await adapter.getPatient('78');

      expect(mockAxios.get).toHaveBeenCalledWith('/demographics/78');
      expect(result.id).toBe('78');
    });

    it('should return null for non-existent patient', async () => {
      mockAxios.get.mockResolvedValue({ data: { success: false, error: 'Not found' } });

      const result = await adapter.getPatient('999999');

      expect(result).toBeNull();
    });
  });

  describe('createPatient()', () => {
    it('should create patient with admission record', async () => {
      mockAxios.post.mockResolvedValue({
        data: { success: true, data: { demographicNo: 80, admissionId: 72, programId: 10034 } }
      });

      const patientData = {
        firstName: 'New',
        lastName: 'Patient',
        dateOfBirth: '1990-05-15',
        phone: '6045559999'
      };

      const result = await adapter.createPatient(patientData);

      expect(mockAxios.post).toHaveBeenCalledWith('/demographics', expect.objectContaining({
        firstName: 'New',
        lastName: 'Patient'
      }));
      expect(result.id).toBe('80');
    });

    it('should handle duplicate patient error', async () => {
      mockAxios.post.mockResolvedValue({
        data: { success: false, error: 'Duplicate patient', status: 409 }
      });

      await expect(adapter.createPatient({ firstName: 'Dup', lastName: 'Patient' }))
        .rejects.toThrow('Patient already exists');
    });
  });

  describe('getAvailability()', () => {
    it('should return available time slots', async () => {
      mockAxios.get.mockResolvedValue({
        data: {
          success: true,
          data: {
            providerId: '100',
            date: '2026-01-15',
            holiday: false,
            timeSlots: [
              { startTime: '09:00', endTime: '09:15', status: 'available', code: 'B' },
              { startTime: '09:15', endTime: '09:30', status: 'available', code: 'B' }
            ]
          }
        }
      });

      const result = await adapter.getAvailability('100', '2026-01-15');

      expect(result.timeSlots).toHaveLength(2);
      expect(result.timeSlots[0].status).toBe('available');
    });

    it('should handle no schedule configured', async () => {
      mockAxios.get.mockResolvedValue({
        data: { success: true, data: { providerId: '129', date: '2026-01-15', timeSlots: [] } }
      });

      const result = await adapter.getAvailability('129', '2026-01-15');

      expect(result.timeSlots).toEqual([]);
    });
  });

  describe('createAppointment()', () => {
    it('should create appointment and return confirmation', async () => {
      mockAxios.post.mockResolvedValue({
        data: { success: true, data: { appointmentNo: 99001 } }
      });

      const apptData = {
        patientId: '78',
        providerId: '100',
        startTime: '2026-01-15T09:00:00',
        duration: 15,
        reason: 'Follow-up'
      };

      const result = await adapter.createAppointment(apptData);

      expect(result.id).toBe('99001');
    });

    it('should handle slot already booked', async () => {
      mockAxios.post.mockResolvedValue({
        data: { success: false, error: 'Slot unavailable', status: 409 }
      });

      await expect(adapter.createAppointment({
        patientId: '78',
        providerId: '100',
        startTime: '2026-01-15T09:00:00',
        duration: 15
      })).rejects.toThrow('Time slot is no longer available');
    });
  });

  describe('cancelAppointment()', () => {
    it('should cancel appointment successfully', async () => {
      mockAxios.delete.mockResolvedValue({ data: { success: true } });

      const result = await adapter.cancelAppointment('99001');

      expect(mockAxios.delete).toHaveBeenCalledWith('/appointments/99001');
      expect(result).toBe(true);
    });
  });

  describe('healthCheck()', () => {
    it('should return healthy status when all services connected', async () => {
      mockAxios.get.mockResolvedValue({
        data: {
          status: 'healthy',
          services: { soap: 'connected', database: 'connected' }
        }
      });

      const result = await adapter.healthCheck();

      expect(result.status).toBe('healthy');
    });

    it('should return degraded status when service down', async () => {
      mockAxios.get.mockResolvedValue({
        data: {
          status: 'degraded',
          services: { soap: 'disconnected', database: 'connected' }
        }
      });

      const result = await adapter.healthCheck();

      expect(result.status).toBe('degraded');
    });
  });
});

3. Patient Transformer Tests

// __tests__/transformers/PatientTransformer.test.js

describe('PatientTransformer', () => {
  describe('fromOscarUniversal()', () => {
    it('should transform OSCAR response to canonical Patient', () => {
      const oscarPatient = {
        demographicNo: 78,
        firstName: 'John',
        lastName: 'Doe',
        dateOfBirth: '1985-03-15',
        sex: 'M',
        phone: '6045551234',
        email: 'john@example.com',
        address: '123 Main St',
        city: 'Vancouver',
        province: 'BC',
        postal: 'V6B1A1',
        hin: '9876543210'
      };

      const result = PatientTransformer.fromOscarUniversal(oscarPatient);

      expect(result).toEqual({
        id: '78',
        firstName: 'John',
        lastName: 'Doe',
        dateOfBirth: '1985-03-15',
        gender: 'M',
        phone: '6045551234',
        email: 'john@example.com',
        address: {
          street: '123 Main St',
          city: 'Vancouver',
          province: 'BC',
          postalCode: 'V6B1A1',
          country: 'CA'
        },
        healthCardNumber: '9876543210',
        healthCardProvince: 'BC'
      });
    });

    it('should handle missing optional fields', () => {
      const oscarPatient = {
        demographicNo: 79,
        firstName: 'Jane',
        lastName: 'Smith'
      };

      const result = PatientTransformer.fromOscarUniversal(oscarPatient);

      expect(result.id).toBe('79');
      expect(result.phone).toBeUndefined();
      expect(result.address).toBeUndefined();
    });
  });

  describe('toOscarUniversal()', () => {
    it('should transform canonical Patient to OSCAR format', () => {
      const patient = {
        firstName: 'New',
        lastName: 'Patient',
        dateOfBirth: '1990-05-20',
        gender: 'F',
        phone: '6045559999',
        address: {
          street: '456 Oak Ave',
          city: 'Victoria',
          province: 'BC',
          postalCode: 'V8W2A1',
          country: 'CA'
        }
      };

      const result = PatientTransformer.toOscarUniversal(patient);

      expect(result).toEqual({
        firstName: 'New',
        lastName: 'Patient',
        dateOfBirth: '1990-05-20',
        sex: 'F',
        phone: '6045559999',
        address: '456 Oak Ave',
        city: 'Victoria',
        province: 'BC',
        postal: 'V8W2A1'
      });
    });
  });
});

Integration Tests

// __tests__/integration/EmrAdapter.integration.test.js

describe('EMR Adapter Integration Tests', () => {
  let adapter;

  beforeAll(() => {
    // Use test REST Bridge instance
    adapter = EmrAdapterFactory.createAdapter({
      emrType: 'oscar-universal',
      bridgeUrl: process.env.TEST_OSCAR_BRIDGE_URL || 'http://15.222.50.48:3000/api/v1',
      apiKey: process.env.TEST_OSCAR_API_KEY
    });
  });

  describe('Patient Workflow', () => {
    it('should search for existing patient by phone', async () => {
      const patients = await adapter.searchPatient({ phone: '2367770690' });

      expect(patients.length).toBeGreaterThan(0);
      expect(patients[0]).toHaveProperty('id');
      expect(patients[0]).toHaveProperty('firstName');
    });

    it('should get patient details by ID', async () => {
      const patient = await adapter.getPatient('78');

      expect(patient).not.toBeNull();
      expect(patient.firstName).toBe('Charles');
      expect(patient.lastName).toBe('Don');
    });
  });

  describe('Provider Workflow', () => {
    it('should list all active providers', async () => {
      const providers = await adapter.getProviders();

      expect(providers.length).toBeGreaterThan(0);
      expect(providers[0]).toHaveProperty('id');
      expect(providers[0]).toHaveProperty('displayName');
    });
  });

  describe('Appointment Workflow', () => {
    it('should get provider availability', async () => {
      const tomorrow = new Date();
      tomorrow.setDate(tomorrow.getDate() + 1);
      const dateStr = tomorrow.toISOString().split('T')[0];

      const availability = await adapter.getAvailability('100', dateStr);

      expect(availability).toHaveProperty('providerId', '100');
      expect(availability).toHaveProperty('timeSlots');
    });

    it('should complete full booking cycle', async () => {
      // 1. Get availability
      const availability = await adapter.getAvailability('100', '2026-01-20');
      const availableSlot = availability.timeSlots.find(s => s.status === 'available');

      if (!availableSlot) {
        console.log('No available slots for booking test');
        return;
      }

      // 2. Create appointment
      const appointment = await adapter.createAppointment({
        patientId: '78',
        providerId: '100',
        startTime: `2026-01-20T${availableSlot.startTime}:00`,
        duration: 15,
        reason: 'Integration Test'
      });

      expect(appointment).toHaveProperty('id');

      // 3. Cancel appointment (cleanup)
      const cancelled = await adapter.cancelAppointment(appointment.id);
      expect(cancelled).toBe(true);
    });
  });
});

End-to-End Tests

// __tests__/e2e/VoiceAgentE2E.test.js

describe('Voice Agent E2E Tests', () => {
  const baseUrl = process.env.VOICE_AGENT_URL || 'https://api-dev.vitaravox.ca:9443';
  const bearerToken = process.env.VAPI_BEARER_TOKEN;

  describe('Patient Search Flow', () => {
    it('should find patient by phone through voice endpoint', async () => {
      const response = await fetch(`${baseUrl}/api/vapi/search-patient-by-phone`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${bearerToken}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          message: {
            toolCalls: [{
              function: {
                arguments: { phone: '2367770690' }
              }
            }]
          }
        })
      });

      const data = await response.json();

      expect(response.status).toBe(200);
      expect(data.results[0].result).toContain('found');
    });

    it('should return not found for unknown phone', async () => {
      const response = await fetch(`${baseUrl}/api/vapi/search-patient-by-phone`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${bearerToken}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          message: {
            toolCalls: [{
              function: {
                arguments: { phone: '0000000000' }
              }
            }]
          }
        })
      });

      const data = await response.json();

      expect(data.results[0].result).toContain('not found');
    });
  });

  describe('Appointment Booking Flow', () => {
    it('should find earliest appointment for provider', async () => {
      const response = await fetch(`${baseUrl}/api/vapi/find-earliest`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${bearerToken}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          message: {
            toolCalls: [{
              function: {
                arguments: { providerId: '100' }
              }
            }]
          }
        })
      });

      const data = await response.json();

      expect(response.status).toBe(200);
      // Should return either appointment or "no availability"
    });
  });

  describe('Multi-Clinic Routing', () => {
    it('should route to correct clinic based on phone', async () => {
      // This tests that the adapter factory loads the right adapter for each clinic
      const response = await fetch(`${baseUrl}/api/vapi/get-clinic-info`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${bearerToken}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          message: {
            call: {
              customer: { number: '+16045550001' }
            },
            toolCalls: [{
              function: { arguments: {} }
            }]
          }
        })
      });

      const data = await response.json();

      expect(response.status).toBe(200);
      expect(data.results[0].result).toContain('clinic');
    });
  });
});

Test Coverage Requirements

Category Target Coverage Metrics
Unit Tests 90% Lines, branches, functions
Integration Tests 80% API endpoints
E2E Tests 100% of happy paths Voice workflows

Test Pyramid

                   /│\
                  / │ \      E2E Tests (10%)
                 /  │  \     - Full voice workflows
                /───┼───\    - Multi-clinic routing
               /    │    \
              /     │     \   Integration Tests (30%)
             /      │      \  - Adapter + Real EMR
            /───────┼───────\ - Database operations
           /        │        \
          /         │         \  Unit Tests (60%)
         /          │          \ - Adapters
        /───────────┼───────────\- Transformers
       /            │            \- Factory
      ──────────────┴──────────────

Rollout Strategy

Feature Flags

// config/featureFlags.js
module.exports = {
  USE_EMR_ADAPTER: process.env.USE_EMR_ADAPTER === 'true' || false,
  EMR_ADAPTER_CLINICS: process.env.EMR_ADAPTER_CLINICS?.split(',') || []
};

Gradual Rollout

  1. Alpha (Week 1): Single test clinic with oscar-universal adapter
  2. Beta (Week 2): 3 clinics, both OSCAR types
  3. GA (Week 3): All clinics migrated to adapter pattern

Rollback Plan

# Immediate rollback: Set feature flag to false
export USE_EMR_ADAPTER=false
docker restart vitara-voice-agent

# Full rollback: Revert to previous container version
docker pull vitara-voice-agent:v1.3.2
docker-compose up -d

Success Criteria

Metric Target Measurement
Test Coverage >85% Jest coverage report
API Latency <500ms p95 Health endpoint metrics
Error Rate <1% Log analysis
Booking Success >95% Call outcome tracking
Zero Downtime 100% During migration