import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn(), })); vi.stubGlobal('fetch', mockFetch); import { AIModelService, _resetCacheForTesting } from '../../src/services/ai/ai-model.service'; describe('AIModelService', () => { beforeEach(() => { mockFetch.mockReset(); _resetCacheForTesting(); }); it('fetches the public OpenRouter catalog with all output modalities and caches it', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [ { id: 'openai/gpt-image', created: 1767225600, architecture: { input_modalities: ['image', 'text'], output_modalities: ['video', 'text', 'embeddings'], }, pricing: { prompt: '0.000001', completion: '0.000002', image: '0.02', }, }, { id: 'openai/whisper-large-v3', created: 1777248000, architecture: { input_modalities: ['audio'], output_modalities: ['transcription'], }, pricing: { prompt: '0.111', completion: '0', }, }, { id: 'google/veo', created: 1777334400, architecture: { input_modalities: ['text'], output_modalities: ['video'], }, pricing: { prompt: '0.000001', completion: '0', request: '0.5', }, }, ], }), }); const [firstResult, secondResult] = await Promise.all([ AIModelService.getModels(), AIModelService.getModels(), ]); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith( 'https://openrouter.ai/api/v1/models?output_modalities=all' ); expect(firstResult).toEqual(secondResult); expect(firstResult).toEqual([ { id: 'openai/gpt-image', created: 1767225600, modelId: 'openai/gpt-image', provider: 'openrouter', inputModality: ['text', 'image'], outputModality: ['text', 'video', 'embeddings'], inputPrice: 1, outputPrice: 2, inputPriceLabel: '$1.0 / M tokens', outputPriceLabel: '$2.0 / M tokens', }, { id: 'openai/whisper-large-v3', created: 1777248000, modelId: 'openai/whisper-large-v3', provider: 'openrouter', inputModality: ['audio'], outputModality: ['transcription'], inputPrice: undefined, outputPrice: undefined, inputPriceLabel: undefined, outputPriceLabel: undefined, }, { id: 'google/veo', created: 1777334400, modelId: 'google/veo', provider: 'openrouter', inputModality: ['text'], outputModality: ['video'], inputPrice: 1, outputPrice: undefined, inputPriceLabel: '$1.0 / M tokens', outputPriceLabel: undefined, }, ]); }); it('clears in-flight state after a failed fetch so a later call retries', async () => { mockFetch .mockResolvedValueOnce({ ok: false, statusText: 'Too Many Requests', }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [], }), }); // 1. First batch of concurrent calls should reject await expect( Promise.all([AIModelService.getModels(), AIModelService.getModels()]) ).rejects.toThrow(); // 2. Advance time by 6 seconds so the 5s negative cache expires const baseTime = Date.now(); const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => baseTime + 6000); try { // 3. The next call should trigger a fresh fetch await AIModelService.getModels(); // 4. Since the first two shared a fetch, and the third triggered a new one, the total should be 2. expect(mockFetch).toHaveBeenCalledTimes(2); } finally { dateSpy.mockRestore(); } }); it('serves stale cache on upstream failure and updates cache expiration to avoid pounding', async () => { // 1. Populate the cache with a successful fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [ { id: 'openai/gpt-image', created: 1767225600, architecture: { input_modalities: ['image', 'text'], output_modalities: ['video', 'text', 'embeddings'], }, pricing: { prompt: '0.000001', completion: '0.000002', }, }, ], }), }); const initialResult = await AIModelService.getModels(); expect(mockFetch).toHaveBeenCalledTimes(1); // 2. Advance time by 2 hours so cache becomes stale (TTL is 1 hour) const baseTime = Date.now(); const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => baseTime + 2 * 60 * 60 * 1000); try { // 3. Mock fetch failure for subsequent request mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Internal Server Error', }); // 4. Retrieve models again - should return stale cache instead of throwing const staleResult = await AIModelService.getModels(); expect(staleResult).toEqual(initialResult); expect(mockFetch).toHaveBeenCalledTimes(2); } finally { // Restore Date.now mock dateSpy.mockRestore(); } }); it('serves stale cache on genuine network rejection, extends cache expiry, and does not throw', async () => { // 1. Populate the cache with a successful fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [ { id: 'openai/gpt-image', created: 1767225600, architecture: { input_modalities: ['image', 'text'], output_modalities: ['video', 'text', 'embeddings'], }, pricing: { prompt: '0.000001', completion: '0.000002', }, }, ], }), }); const initialResult = await AIModelService.getModels(); expect(mockFetch).toHaveBeenCalledTimes(1); // 2. Advance time by 2 hours so cache becomes stale (TTL is 1 hour) const baseTime = Date.now(); const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => baseTime + 2 * 60 * 60 * 1000); try { // 3. Mock genuine network rejection for subsequent request mockFetch.mockRejectedValueOnce(new Error('Network timeout')); // 4. Retrieve models again - should return stale cache instead of throwing const staleResult = await AIModelService.getModels(); expect(staleResult).toEqual(initialResult); expect(mockFetch).toHaveBeenCalledTimes(2); } finally { // Restore Date.now mock dateSpy.mockRestore(); } }); });