Savvi Studio

Mock System Architecture

Date: November 7, 2025 Status: Design Phase Approach: Utility-Based Mock Extraction (Not Wholesale Migration)

Philosophy

The centralized mock system provides reusable utilities and presets to reduce duplication while maintaining the flexibility and clarity of inline mocking. Tests can continue using inline mocks while leveraging shared factories for common patterns.

Core Principles

  1. Utilities, Not Requirements - Centralized mocks are helpers, not mandatory
  2. Maintain Inline Flexibility - Tests can override/customize any behavior
  3. Reduce Duplication - Extract repeated patterns into factories
  4. Improve Discoverability - Easy to find and use common mocks
  5. Type Safety First - Fully typed mock interfaces
  6. Clear Documentation - Every pattern documented with examples

Directory Structure

tests/
├── mocks/
│   ├── index.ts                          # Central exports barrel file
│   ├── README.md                         # Quick start guide
│   │
│   ├── db/                               # Database mocks
│   │   ├── index.ts                      # DB exports
│   │   ├── pool-client.mock.ts           # PoolClient factory
│   │   ├── query-result.fixtures.ts      # Query result test data
│   │   ├── transaction.mock.ts           # Transaction utilities
│   │   └── presets.ts                    # Common DB scenarios
│   │
│   ├── auth/                             # Auth system mocks
│   │   ├── index.ts                      # Auth exports
│   │   ├── secret-store.mock.ts          # SecretStore factory
│   │   ├── auth-service.mock.ts          # AuthService spy helpers
│   │   ├── jwt-claim.fixtures.ts         # JWT test data
│   │   ├── key-material.fixtures.ts      # Encryption key fixtures
│   │   └── presets.ts                    # Common auth scenarios
│   │
│   ├── services/                         # Service layer mocks
│   │   ├── index.ts
│   │   └── [future service mocks]
│   │
│   └── models/                           # Model fixtures
│       ├── index.ts
│       └── [future model fixtures]
│
└── integration/
    └── setup/                            # Existing integration mocks (unchanged)
        ├── factories/
        ├── mocks.ts
        └── utilities/

Naming Conventions

Files

  • Factories: *.mock.ts - Mock factory functions
  • Fixtures: *.fixtures.ts - Test data constants
  • Presets: presets.ts - Pre-configured scenarios
  • Exports: index.ts - Barrel files for clean imports

Functions

  • Factories: create[Type]Mock() - Returns mock instance

    createPoolClientMock()
    createSecretStoreMock()
    createAuthServiceMock()
    
  • Presets: [Type]Presets.[scenario] - Pre-configured mocks

    PoolClientPresets.withSuccessfulQuery()
    AuthServicePresets.withValidJWT()
    SecretStorePresets.withMasterStore()
    
  • Fixtures: [Type]Fixtures.[variant] - Test data

    JWTClaimFixtures.forRegularUser
    QueryResultFixtures.emptyResult
    KeyMaterialFixtures.validRSAKey
    

Design Patterns

Pattern 1: Factory Functions

Purpose: Create mock instances with sensible defaults and override capability

Structure:

/**
 * Creates a mock [Interface] with default behavior
 * @param overrides - Partial overrides for specific methods/properties
 * @returns Mock instance implementing [Interface]
 */
export function create[Type]Mock(
  overrides?: Partial<Interface>
): Interface {
  return {
    // Default implementations
    method1: vi.fn().mockResolvedValue(defaultValue),
    method2: vi.fn().mockResolvedValue(defaultValue),
    ...overrides  // Allow customization
  };
}

Key Features:

  • ✅ Sensible defaults for all methods
  • ✅ Partial override support via spread operator
  • ✅ Full TypeScript type safety
  • ✅ Explicit mock behavior (vi.fn() visible)

Example:

// tests/mocks/db/pool-client.mock.ts
import { vi } from 'vitest';
import type { PoolClient } from 'pg';

export function createPoolClientMock(
  overrides?: Partial<PoolClient>
): PoolClient {
  return {
    query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
    release: vi.fn().mockResolvedValue(undefined),
    ...overrides
  } as PoolClient;
}

Pattern 2: Preset Configurations

Purpose: Provide pre-configured mocks for common test scenarios

Structure:

export const [Type]Presets = {
  /**
   * Description of scenario
   * @param params - Optional parameters for customization
   * @returns Pre-configured mock
   */
  scenarioName: (params?: ScenarioParams) => create[Type]Mock({
    // Specific overrides for this scenario
  }),
  
  anotherScenario: () => create[Type]Mock({
    // Different overrides
  })
};

Key Features:

  • ✅ Named scenarios for common use cases
  • ✅ Built on top of factory functions
  • ✅ Optional parameters for variation
  • ✅ Self-documenting test intent

Example:

// tests/mocks/db/presets.ts
import { createPoolClientMock } from './pool-client.mock';
import { QueryResultFixtures } from './query-result.fixtures';

export const PoolClientPresets = {
  /** Mock client that returns successful query results */
  withSuccessfulQuery: (rows: any[] = []) => 
    createPoolClientMock({
      query: vi.fn().mockResolvedValue({ 
        rows, 
        rowCount: rows.length 
      })
    }),
  
  /** Mock client that simulates query failure */
  withQueryFailure: (error = new Error('Query failed')) =>
    createPoolClientMock({
      query: vi.fn().mockRejectedValue(error)
    }),
  
  /** Mock client that simulates connection error */
  withConnectionError: () =>
    createPoolClientMock({
      query: vi.fn().mockRejectedValue(new Error('Connection lost')),
      release: vi.fn().mockRejectedValue(new Error('Connection lost'))
    })
};

Pattern 3: Fixture Constants

Purpose: Centralize test data for reusability and consistency

Structure:

/**
 * Test fixtures for [DataType]
 */
export const [DataType]Fixtures = {
  /** Description of variant */
  variantName: {
    // Test data
  } as DataType,
  
  /** Another variant */
  anotherVariant: {
    // Different data
  } as DataType,
  
  /** Factory function for dynamic fixtures */
  create: (overrides?: Partial<DataType>): DataType => ({
    // Defaults
    ...overrides
  })
};

Key Features:

  • ✅ Typed fixture data
  • ✅ Multiple variants for different scenarios
  • ✅ Factory functions for dynamic generation
  • ✅ Composable fixtures

Example:

// tests/mocks/auth/jwt-claim.fixtures.ts
import type { JWTClaims } from '@/lib/auth/types';

export const JWTClaimFixtures = {
  /** Standard claims for a regular user */
  forRegularUser: {
    sub: 'user-123',
    email: 'user@example.com',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600,
    role: 'user'
  } as JWTClaims,
  
  /** Claims for an admin user */
  forAdmin: {
    sub: 'admin-456',
    email: 'admin@example.com',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600,
    role: 'admin',
    permissions: ['read', 'write', 'admin']
  } as JWTClaims,
  
  /** Expired token claims */
  expired: {
    sub: 'user-789',
    email: 'expired@example.com',
    iat: Math.floor(Date.now() / 1000) - 7200,
    exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
    role: 'user'
  } as JWTClaims,
  
  /** Factory for creating custom claims */
  create: (overrides?: Partial<JWTClaims>): JWTClaims => ({
    sub: 'custom-user',
    email: 'custom@example.com',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600,
    role: 'user',
    ...overrides
  })
};

Pattern 4: Spy Helpers

Purpose: Simplify common spy setups for complex objects

Structure:

/**
 * Helper utilities for spying on [Service]
 */
export const [Service]SpyHelpers = {
  /**
   * Sets up spies for scenario
   */
  setupScenarioSpies: (service: Service, scenario: Scenario) => {
    // Multiple vi.spyOn() calls
    // Return cleanup function or spy references
  },
  
  /** Common spy configurations */
  mockMethod: (service: Service, behavior: Behavior) => {
    return vi.spyOn(service, 'method').mockImplementation(behavior);
  }
};

Example:

// tests/mocks/auth/auth-service.mock.ts
import { vi } from 'vitest';
import type { AuthService } from '@/lib/auth/service';
import { JWTClaimFixtures } from './jwt-claim.fixtures';

export const AuthServiceSpyHelpers = {
  /** Mock successful JWT validation */
  mockValidJWT: (service: AuthService, claims = JWTClaimFixtures.forRegularUser) => {
    vi.spyOn(service.validation(), 'resolveKeyId').mockResolvedValue({
      key_id: 'jwt-key-v1',
      error_message: null,
      header: { alg: 'RS256' },
      payload: claims
    });
    
    vi.spyOn(service.validation(), 'verifyJWT').mockResolvedValue({
      valid: true,
      error_message: null,
      header: { alg: 'RS256' },
      claims
    });
    
    return { claims };
  },
  
  /** Mock expired JWT */
  mockExpiredJWT: (service: AuthService) => {
    vi.spyOn(service.validation(), 'verifyJWT').mockResolvedValue({
      valid: false,
      error_message: 'Token expired',
      header: { alg: 'RS256' },
      claims: null
    });
  },
  
  /** Mock invalid JWT signature */
  mockInvalidSignature: (service: AuthService) => {
    vi.spyOn(service.validation(), 'verifyJWT').mockResolvedValue({
      valid: false,
      error_message: 'Invalid signature',
      header: { alg: 'RS256' },
      claims: null
    });
  }
};

Export Strategy

Barrel Files (index.ts)

Each category has an index.ts that re-exports its contents:

// tests/mocks/db/index.ts
export * from './pool-client.mock';
export * from './query-result.fixtures';
export * from './transaction.mock';
export * from './presets';

Root Barrel File

The main tests/mocks/index.ts provides centralized access:

// tests/mocks/index.ts

// Database mocks
export * from './db';

// Auth mocks
export * from './auth';

// Service mocks (future)
export * from './services';

// Model fixtures (future)
export * from './models';

Import Patterns

Option 1: Import from root (recommended)

import { 
  createPoolClientMock,
  PoolClientPresets,
  JWTClaimFixtures,
  AuthServiceSpyHelpers
} from '@tests/mocks';

Option 2: Import from category

import { createPoolClientMock, PoolClientPresets } from '@tests/mocks/db';
import { JWTClaimFixtures } from '@tests/mocks/auth';

Option 3: Import specific file (rare)

import { createPoolClientMock } from '@tests/mocks/db/pool-client.mock';

TypeScript Configuration

Path Aliases

Ensure tsconfig.json includes the mock path:

{
  "compilerOptions": {
    "paths": {
      "@tests/mocks": ["./tests/mocks"],
      "@tests/mocks/*": ["./tests/mocks/*"]
    }
  }
}

Type Safety

All mock factories return properly typed interfaces:

// ✅ Correct: Fully typed
export function createPoolClientMock(
  overrides?: Partial<PoolClient>
): PoolClient {
  // ...
}

// ❌ Incorrect: Loose typing
export function createPoolClientMock(overrides?: any): any {
  // ...
}

Usage Examples

Example 1: Basic Factory Usage

import { describe, it, expect, beforeEach } from 'vitest';
import { createPoolClientMock } from '@tests/mocks';
import { AuthAPI } from '@/lib/auth/services/api/AuthAPI';

describe('AuthAPI', () => {
  let client: PoolClient;
  let api: AuthAPI;
  
  beforeEach(() => {
    // Simple: Use factory with defaults
    client = createPoolClientMock();
    api = new AuthAPI(client);
  });
  
  it('should initialize successfully', async () => {
    await expect(api.initialize()).resolves.not.toThrow();
  });
});

Example 2: Factory with Overrides

import { createPoolClientMock } from '@tests/mocks';

describe('Error handling', () => {
  it('should handle query failures', async () => {
    // Override specific method behavior
    const client = createPoolClientMock({
      query: vi.fn().mockRejectedValue(new Error('Connection lost'))
    });
    
    await expect(someOperation(client)).rejects.toThrow('Connection lost');
  });
});

Example 3: Using Presets

import { PoolClientPresets } from '@tests/mocks';

describe('Query scenarios', () => {
  it('should handle successful queries', async () => {
    // Use preset for common scenario
    const client = PoolClientPresets.withSuccessfulQuery([
      { id: 1, name: 'Test' }
    ]);
    
    const result = await someQuery(client);
    expect(result).toHaveLength(1);
  });
  
  it('should handle query failures', async () => {
    const client = PoolClientPresets.withQueryFailure();
    await expect(someQuery(client)).rejects.toThrow('Query failed');
  });
});

Example 4: Using Fixtures

import { JWTClaimFixtures, AuthServiceSpyHelpers } from '@tests/mocks';

describe('JWT validation', () => {
  it('should validate regular user token', async () => {
    const service = createAuthService();
    
    // Use fixture for test data
    AuthServiceSpyHelpers.mockValidJWT(
      service, 
      JWTClaimFixtures.forRegularUser
    );
    
    const result = await api.verifyAndValidateJWT({ jwt: 'token' });
    expect(result.valid).toBe(true);
    expect(result.claims).toEqual(JWTClaimFixtures.forRegularUser);
  });
  
  it('should reject expired tokens', async () => {
    const service = createAuthService();
    AuthServiceSpyHelpers.mockExpiredJWT(service);
    
    const result = await api.verifyAndValidateJWT({ jwt: 'expired-token' });
    expect(result.valid).toBe(false);
  });
});

Example 5: Complex Scenario Composition

import { 
  createPoolClientMock,
  createSecretStoreMock,
  JWTClaimFixtures,
  KeyMaterialFixtures
} from '@tests/mocks';

describe('Complex auth flow', () => {
  it('should complete full JWT lifecycle', async () => {
    // Compose multiple mocks for complex scenarios
    const client = createPoolClientMock({
      query: vi.fn()
        .mockResolvedValueOnce({ rows: [KeyMaterialFixtures.validRSAKey] })
        .mockResolvedValueOnce({ rows: [], rowCount: 0 })
    });
    
    const store = createSecretStoreMock({
      getSecret: vi.fn().mockResolvedValue({
        id: 'jwt-key-v1',
        value: KeyMaterialFixtures.validRSAKey.value,
        metadata: { algorithm: 'RS256' }
      })
    });
    
    // Test complex scenario...
  });
});

Migration Strategy

Gradual Adoption

  1. New Tests: Use centralized mocks from day one
  2. Existing Tests: Migrate opportunistically (when touching test files)
  3. Critical Paths: Prioritize high-traffic test files
  4. No Force Migration: Allow both patterns to coexist

Migration Process

Before:

const createMockClient = (): PoolClient => {
  return {
    query: vi.fn(),
    release: vi.fn(),
  } as unknown as PoolClient;
};

let client: PoolClient;
beforeEach(() => {
  client = createMockClient();
});

After:

import { createPoolClientMock } from '@tests/mocks';

let client: PoolClient;
beforeEach(() => {
  client = createPoolClientMock();
});

Steps:

  1. Import centralized mock
  2. Replace inline factory call
  3. Remove inline factory definition
  4. Verify tests still pass
  5. (Optional) Refactor to use presets if applicable

Guidelines for Creating New Mocks

When to Create a Factory

Create a factory when:

  • ✅ Pattern appears in 2+ test files
  • ✅ Mock is complex (5+ methods/properties)
  • ✅ Interface changes frequently
  • ✅ Multiple test scenarios exist

Don't create a factory when:

  • ❌ Used in only one test file
  • ❌ Mock is trivial (1-2 properties)
  • ❌ Highly test-specific behavior

When to Create a Preset

Create a preset when:

  • ✅ Scenario appears in 3+ tests
  • ✅ Configuration is non-trivial
  • ✅ Scenario has clear business meaning
  • ✅ Reduces test setup complexity

When to Create a Fixture

Create a fixture when:

  • ✅ Data structure appears in 2+ tests
  • ✅ Data represents domain concept
  • ✅ Multiple variants needed
  • ✅ Data is complex or verbose

Quality Standards

Code Quality

  • ✅ Full TypeScript type safety
  • ✅ JSDoc comments on all exports
  • ✅ Consistent naming conventions
  • ✅ No any types
  • ✅ Proper error handling

Documentation

  • ✅ Clear purpose statement
  • ✅ Usage examples in comments
  • ✅ Parameter descriptions
  • ✅ Return value descriptions

Testing

  • ✅ Mocks themselves don't need tests
  • ✅ Example usage in documentation
  • ✅ Verify in consuming tests

Maintenance

Updating Mocks

When interfaces change:

  1. Update the factory function
  2. Update affected presets
  3. Update fixtures if needed
  4. Run full test suite
  5. Update documentation

Deprecating Mocks

When mocks become obsolete:

  1. Mark as @deprecated in JSDoc
  2. Provide migration path
  3. Keep for 1-2 release cycles
  4. Remove after migration period

Adding New Mocks

  1. Follow established patterns
  2. Add to appropriate category
  3. Export from index files
  4. Document in README
  5. Provide usage examples

Success Metrics

Quantitative

  • Reduce mock code duplication by 60%+
  • Decrease test setup time by 30%+
  • Increase test file consistency to 90%+

Qualitative

  • Easier to write new tests
  • Clearer test intent
  • Simpler maintenance
  • Better discoverability

Next Phase

After architecture approval, proceed to Phase 3: Create Infrastructure

This involves:

  1. Create directory structure
  2. Implement base mock utilities
  3. Set up TypeScript configuration
  4. Create template files
  5. Document patterns