import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RoutingEngineService } from './routing-engine.service.js'; import type { RoutingRule, TaskClassification } from './routing.types.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── function makeRule( overrides: Partial & Pick, ): RoutingRule { return { id: overrides.id ?? crypto.randomUUID(), scope: 'system', enabled: true, ...overrides, }; } function makeClassification(overrides: Partial = {}): TaskClassification { return { taskType: 'conversation', complexity: 'simple', domain: 'general', requiredCapabilities: [], ...overrides, }; } /** Build a minimal RoutingEngineService with mocked DB and ProviderService. */ function makeService( rules: RoutingRule[] = [], healthMap: Record = {}, ): RoutingEngineService { const mockDb = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockResolvedValue( rules.map((r) => ({ id: r.id, name: r.name, priority: r.priority, scope: r.scope, userId: r.userId ?? null, conditions: r.conditions, action: r.action, enabled: r.enabled, createdAt: new Date(), updatedAt: new Date(), })), ), }), }), }), }; const mockProviderService = { healthCheckAll: vi.fn().mockResolvedValue(healthMap), }; // Inject mocked dependencies directly (bypass NestJS DI for unit tests) const service = new (RoutingEngineService as unknown as new ( db: unknown, ps: unknown, ) => RoutingEngineService)(mockDb, mockProviderService); return service; } // ─── matchConditions ────────────────────────────────────────────────────────── describe('RoutingEngineService.matchConditions', () => { let service: RoutingEngineService; beforeEach(() => { service = makeService(); }); it('returns true for empty conditions array (catch-all rule)', () => { const rule = makeRule({ name: 'fallback', priority: 99, conditions: [], action: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, }); expect(service.matchConditions(rule, makeClassification())).toBe(true); }); it('matches eq operator on scalar field', () => { const rule = makeRule({ name: 'coding', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }); expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(true); expect(service.matchConditions(rule, makeClassification({ taskType: 'conversation' }))).toBe( false, ); }); it('matches in operator: field value is in the condition array', () => { const rule = makeRule({ name: 'simple or moderate', priority: 2, conditions: [{ field: 'complexity', operator: 'in', value: ['simple', 'moderate'] }], action: { provider: 'anthropic', model: 'claude-haiku-4-5' }, }); expect(service.matchConditions(rule, makeClassification({ complexity: 'simple' }))).toBe(true); expect(service.matchConditions(rule, makeClassification({ complexity: 'moderate' }))).toBe( true, ); expect(service.matchConditions(rule, makeClassification({ complexity: 'complex' }))).toBe( false, ); }); it('matches includes operator: field array includes the condition value', () => { const rule = makeRule({ name: 'reasoning required', priority: 3, conditions: [{ field: 'requiredCapabilities', operator: 'includes', value: 'reasoning' }], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }); expect( service.matchConditions(rule, makeClassification({ requiredCapabilities: ['reasoning'] })), ).toBe(true); expect( service.matchConditions( rule, makeClassification({ requiredCapabilities: ['tools', 'reasoning'] }), ), ).toBe(true); expect( service.matchConditions(rule, makeClassification({ requiredCapabilities: ['tools'] })), ).toBe(false); expect(service.matchConditions(rule, makeClassification({ requiredCapabilities: [] }))).toBe( false, ); }); it('requires ALL conditions to match (AND logic)', () => { const rule = makeRule({ name: 'complex coding', priority: 1, conditions: [ { field: 'taskType', operator: 'eq', value: 'coding' }, { field: 'complexity', operator: 'eq', value: 'complex' }, ], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }); // Both match expect( service.matchConditions( rule, makeClassification({ taskType: 'coding', complexity: 'complex' }), ), ).toBe(true); // Only one matches expect( service.matchConditions( rule, makeClassification({ taskType: 'coding', complexity: 'simple' }), ), ).toBe(false); // Neither matches expect( service.matchConditions( rule, makeClassification({ taskType: 'conversation', complexity: 'simple' }), ), ).toBe(false); }); it('returns false for eq when condition value is an array (type mismatch)', () => { const rule = makeRule({ name: 'bad eq', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: ['coding', 'research'] }], action: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, }); expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(false); }); it('returns false for includes when field is not an array', () => { const rule = makeRule({ name: 'bad includes', priority: 1, conditions: [{ field: 'taskType', operator: 'includes', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, }); // taskType is a string, not an array — should be false expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(false); }); }); // ─── resolve — priority ordering ───────────────────────────────────────────── describe('RoutingEngineService.resolve — priority ordering', () => { it('selects the highest-priority matching rule', async () => { // Rules are supplied in priority-ascending order, as the DB would return them. const rules = [ makeRule({ name: 'high priority', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }), makeRule({ name: 'low priority', priority: 10, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'openai', model: 'gpt-4o' }, }), ]; const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } }); const decision = await service.resolve('implement a function'); expect(decision.ruleName).toBe('high priority'); expect(decision.provider).toBe('anthropic'); expect(decision.model).toBe('claude-opus-4-6'); }); it('skips non-matching rules and picks first match', async () => { const rules = [ makeRule({ name: 'research rule', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: 'research' }], action: { provider: 'openai', model: 'gpt-4o' }, }), makeRule({ name: 'coding rule', priority: 2, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, }), ]; const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } }); const decision = await service.resolve('implement a function'); expect(decision.ruleName).toBe('coding rule'); expect(decision.provider).toBe('anthropic'); }); }); // ─── resolve — unhealthy provider fallback ──────────────────────────────────── describe('RoutingEngineService.resolve — unhealthy provider handling', () => { it('skips matched rule when provider is unhealthy, tries next rule', async () => { const rules = [ makeRule({ name: 'primary rule', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }), makeRule({ name: 'secondary rule', priority: 2, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'openai', model: 'gpt-4o' }, }), ]; const service = makeService(rules, { anthropic: { status: 'down' }, // primary is unhealthy openai: { status: 'up' }, }); const decision = await service.resolve('implement a function'); expect(decision.ruleName).toBe('secondary rule'); expect(decision.provider).toBe('openai'); }); it('falls back to Sonnet when all rules have unhealthy providers', async () => { // Override the rule's provider to something unhealthy but keep anthropic up for fallback const unhealthyRules = [ makeRule({ name: 'only rule', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'openai', model: 'gpt-4o' }, // openai is unhealthy }), ]; const service2 = makeService(unhealthyRules, { anthropic: { status: 'up' }, openai: { status: 'down' }, }); const decision = await service2.resolve('implement a function'); // Should fall through to Sonnet fallback on anthropic expect(decision.provider).toBe('anthropic'); expect(decision.model).toBe('claude-sonnet-4-6'); expect(decision.ruleName).toBe('fallback'); }); it('falls back to Haiku when Sonnet provider is also down', async () => { const rules: RoutingRule[] = []; // no rules const service = makeService(rules, { anthropic: { status: 'down' }, // Sonnet is on anthropic — down ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next }); const decision = await service.resolve('hello there'); // Sonnet (anthropic) is down, Haiku (anthropic) is down, Ollama is up expect(decision.provider).toBe('ollama'); expect(decision.model).toBe('llama3.2'); expect(decision.ruleName).toBe('fallback'); }); it('uses last resort (Sonnet) when all fallback providers are unhealthy', async () => { const rules: RoutingRule[] = []; const service = makeService(rules, { anthropic: { status: 'down' }, ollama: { status: 'down' }, }); const decision = await service.resolve('hello'); // All unhealthy — still returns first fallback entry as last resort expect(decision.provider).toBe('anthropic'); expect(decision.model).toBe('claude-sonnet-4-6'); expect(decision.ruleName).toBe('fallback'); }); }); // ─── resolve — empty conditions (catch-all rule) ────────────────────────────── describe('RoutingEngineService.resolve — empty conditions (fallback rule)', () => { it('matches catch-all rule for any message', async () => { const rules = [ makeRule({ name: 'catch-all', priority: 99, conditions: [], action: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, }), ]; const service = makeService(rules, { anthropic: { status: 'up' } }); const decision = await service.resolve('completely unrelated message xyz'); expect(decision.ruleName).toBe('catch-all'); expect(decision.provider).toBe('anthropic'); expect(decision.model).toBe('claude-sonnet-4-6'); }); it('catch-all is overridden by a higher-priority specific rule', async () => { const rules = [ makeRule({ name: 'specific coding rule', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }), makeRule({ name: 'catch-all', priority: 99, conditions: [], action: { provider: 'anthropic', model: 'claude-haiku-4-5' }, }), ]; const service = makeService(rules, { anthropic: { status: 'up' } }); const codingDecision = await service.resolve('implement a function'); expect(codingDecision.ruleName).toBe('specific coding rule'); expect(codingDecision.model).toBe('claude-opus-4-6'); const conversationDecision = await service.resolve('hello how are you'); expect(conversationDecision.ruleName).toBe('catch-all'); expect(conversationDecision.model).toBe('claude-haiku-4-5'); }); }); // ─── resolve — disabled rules ───────────────────────────────────────────────── describe('RoutingEngineService.resolve — disabled rules', () => { it('skips disabled rules', async () => { const rules = [ makeRule({ name: 'disabled rule', priority: 1, enabled: false, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }), makeRule({ name: 'enabled fallback', priority: 99, conditions: [], action: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, }), ]; const service = makeService(rules, { anthropic: { status: 'up' } }); const decision = await service.resolve('implement a function'); expect(decision.ruleName).toBe('enabled fallback'); expect(decision.model).toBe('claude-sonnet-4-6'); }); }); // ─── resolve — pre-fetched health map ──────────────────────────────────────── describe('RoutingEngineService.resolve — availableProviders override', () => { it('uses the provided health map instead of calling healthCheckAll', async () => { const rules = [ makeRule({ name: 'coding rule', priority: 1, conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }], action: { provider: 'anthropic', model: 'claude-opus-4-6' }, }), ]; const mockHealthCheckAll = vi.fn().mockResolvedValue({}); const mockDb = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockResolvedValue( rules.map((r) => ({ id: r.id, name: r.name, priority: r.priority, scope: r.scope, userId: r.userId ?? null, conditions: r.conditions, action: r.action, enabled: r.enabled, createdAt: new Date(), updatedAt: new Date(), })), ), }), }), }), }; const mockProviderService = { healthCheckAll: mockHealthCheckAll }; const service = new (RoutingEngineService as unknown as new ( db: unknown, ps: unknown, ) => RoutingEngineService)(mockDb, mockProviderService); const preSupplied = { anthropic: { status: 'up' } }; await service.resolve('implement a function', undefined, preSupplied); expect(mockHealthCheckAll).not.toHaveBeenCalled(); }); });