/** * SpeechService Tests * * Issue #389: Tests for provider abstraction layer with fallback logic. * Written FIRST following TDD (Red-Green-Refactor). */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { ServiceUnavailableException } from "@nestjs/common"; import { SpeechService } from "./speech.service"; import { STT_PROVIDER, TTS_PROVIDERS } from "./speech.constants"; import { speechConfig } from "./speech.config"; import type { ISTTProvider } from "./interfaces/stt-provider.interface"; import type { ITTSProvider } from "./interfaces/tts-provider.interface"; import type { SpeechTier, TranscriptionResult, SynthesisResult, VoiceInfo, } from "./interfaces/speech-types"; // ========================================== // Mock provider factories // ========================================== function createMockSttProvider(overrides?: Partial): ISTTProvider { return { name: "mock-stt", transcribe: vi.fn().mockResolvedValue({ text: "Hello world", language: "en", durationSeconds: 2.5, } satisfies TranscriptionResult), isHealthy: vi.fn().mockResolvedValue(true), ...overrides, }; } function createMockTtsProvider(tier: SpeechTier, overrides?: Partial): ITTSProvider { return { name: `mock-tts-${tier}`, tier, synthesize: vi.fn().mockResolvedValue({ audio: Buffer.from("fake-audio"), format: "mp3", voice: "test-voice", tier, } satisfies SynthesisResult), listVoices: vi .fn() .mockResolvedValue([ { id: `${tier}-voice-1`, name: `${tier} Voice 1`, tier, isDefault: true }, ] satisfies VoiceInfo[]), isHealthy: vi.fn().mockResolvedValue(true), ...overrides, }; } // ========================================== // Default config for tests // ========================================== function createTestConfig(): ReturnType { return { stt: { enabled: true, baseUrl: "http://localhost:8000/v1", model: "test-model", language: "en", }, tts: { default: { enabled: true, url: "http://localhost:8880/v1", voice: "test-voice", format: "mp3", }, premium: { enabled: true, url: "http://localhost:8881/v1", }, fallback: { enabled: true, url: "http://localhost:8882/v1", }, }, limits: { maxUploadSize: 25_000_000, maxDurationSeconds: 600, maxTextLength: 4096, }, } as ReturnType; } // ========================================== // Test helper: create testing module // ========================================== async function createTestModule(options: { sttProvider?: ISTTProvider | null; ttsProviders?: Map; config?: ReturnType; }): Promise { const config = options.config ?? createTestConfig(); const ttsProviders = options.ttsProviders ?? new Map(); const providers: Array<{ provide: symbol | string; useValue: unknown }> = [ { provide: speechConfig.KEY, useValue: config }, { provide: TTS_PROVIDERS, useValue: ttsProviders }, ]; if (options.sttProvider !== undefined) { providers.push({ provide: STT_PROVIDER, useValue: options.sttProvider }); } return Test.createTestingModule({ providers: [SpeechService, ...providers], }).compile(); } // ========================================== // Tests // ========================================== describe("SpeechService", () => { // ========================================== // Construction and initialization // ========================================== describe("construction", () => { it("should be defined when all providers are injected", async () => { const module = await createTestModule({ sttProvider: createMockSttProvider(), ttsProviders: new Map([["default", createMockTtsProvider("default")]]), }); const service = module.get(SpeechService); expect(service).toBeDefined(); }); it("should be defined with no STT provider", async () => { const module = await createTestModule({ sttProvider: null, ttsProviders: new Map([["default", createMockTtsProvider("default")]]), }); const service = module.get(SpeechService); expect(service).toBeDefined(); }); it("should be defined with empty TTS providers map", async () => { const module = await createTestModule({ sttProvider: createMockSttProvider(), ttsProviders: new Map(), }); const service = module.get(SpeechService); expect(service).toBeDefined(); }); }); // ========================================== // transcribe() // ========================================== describe("transcribe", () => { let service: SpeechService; let mockStt: ISTTProvider; beforeEach(async () => { mockStt = createMockSttProvider(); const module = await createTestModule({ sttProvider: mockStt }); service = module.get(SpeechService); }); it("should delegate to the STT provider", async () => { const audio = Buffer.from("test-audio"); const result = await service.transcribe(audio); expect(mockStt.transcribe).toHaveBeenCalledWith(audio, undefined); expect(result.text).toBe("Hello world"); expect(result.language).toBe("en"); }); it("should pass options to the STT provider", async () => { const audio = Buffer.from("test-audio"); const options = { language: "fr", model: "custom-model" }; await service.transcribe(audio, options); expect(mockStt.transcribe).toHaveBeenCalledWith(audio, options); }); it("should throw ServiceUnavailableException when STT is disabled in config", async () => { const config = createTestConfig(); config.stt.enabled = false; const module = await createTestModule({ sttProvider: mockStt, config }); service = module.get(SpeechService); await expect(service.transcribe(Buffer.from("audio"))).rejects.toThrow( ServiceUnavailableException ); }); it("should throw ServiceUnavailableException when no STT provider is registered", async () => { const module = await createTestModule({ sttProvider: null }); service = module.get(SpeechService); await expect(service.transcribe(Buffer.from("audio"))).rejects.toThrow( ServiceUnavailableException ); }); it("should propagate provider errors as ServiceUnavailableException", async () => { const failingStt = createMockSttProvider({ transcribe: vi.fn().mockRejectedValue(new Error("Connection refused")), }); const module = await createTestModule({ sttProvider: failingStt }); service = module.get(SpeechService); await expect(service.transcribe(Buffer.from("audio"))).rejects.toThrow( ServiceUnavailableException ); }); }); // ========================================== // synthesize() // ========================================== describe("synthesize", () => { let service: SpeechService; let defaultProvider: ITTSProvider; let premiumProvider: ITTSProvider; let fallbackProvider: ITTSProvider; beforeEach(async () => { defaultProvider = createMockTtsProvider("default"); premiumProvider = createMockTtsProvider("premium"); fallbackProvider = createMockTtsProvider("fallback"); const ttsProviders = new Map([ ["default", defaultProvider], ["premium", premiumProvider], ["fallback", fallbackProvider], ]); const module = await createTestModule({ ttsProviders }); service = module.get(SpeechService); }); it("should use the default tier when no tier is specified", async () => { const result = await service.synthesize("Hello world"); expect(defaultProvider.synthesize).toHaveBeenCalledWith("Hello world", undefined); expect(result.tier).toBe("default"); }); it("should use the requested tier when specified", async () => { const result = await service.synthesize("Hello world", { tier: "premium" }); expect(premiumProvider.synthesize).toHaveBeenCalled(); expect(result.tier).toBe("premium"); }); it("should pass options to the TTS provider", async () => { const options = { voice: "custom-voice", format: "wav" as const }; await service.synthesize("Hello", options); expect(defaultProvider.synthesize).toHaveBeenCalledWith("Hello", options); }); it("should throw ServiceUnavailableException when TTS default is disabled and no tier specified", async () => { const config = createTestConfig(); config.tts.default.enabled = false; config.tts.premium.enabled = false; config.tts.fallback.enabled = false; const module = await createTestModule({ ttsProviders: new Map([["default", defaultProvider]]), config, }); service = module.get(SpeechService); await expect(service.synthesize("Hello")).rejects.toThrow(ServiceUnavailableException); }); it("should throw ServiceUnavailableException when no TTS providers are registered", async () => { const module = await createTestModule({ ttsProviders: new Map() }); service = module.get(SpeechService); await expect(service.synthesize("Hello")).rejects.toThrow(ServiceUnavailableException); }); }); // ========================================== // synthesize() fallback logic // ========================================== describe("synthesize fallback", () => { it("should fall back from premium to default when premium provider fails", async () => { const failingPremium = createMockTtsProvider("premium", { synthesize: vi.fn().mockRejectedValue(new Error("Premium unavailable")), }); const defaultProvider = createMockTtsProvider("default"); const ttsProviders = new Map([ ["premium", failingPremium], ["default", defaultProvider], ]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); const result = await service.synthesize("Hello", { tier: "premium" }); expect(failingPremium.synthesize).toHaveBeenCalled(); expect(defaultProvider.synthesize).toHaveBeenCalled(); expect(result.tier).toBe("default"); }); it("should fall back from default to fallback when default provider fails", async () => { const failingDefault = createMockTtsProvider("default", { synthesize: vi.fn().mockRejectedValue(new Error("Default unavailable")), }); const fallbackProvider = createMockTtsProvider("fallback"); const ttsProviders = new Map([ ["default", failingDefault], ["fallback", fallbackProvider], ]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); const result = await service.synthesize("Hello"); expect(failingDefault.synthesize).toHaveBeenCalled(); expect(fallbackProvider.synthesize).toHaveBeenCalled(); expect(result.tier).toBe("fallback"); }); it("should fall back premium -> default -> fallback", async () => { const failingPremium = createMockTtsProvider("premium", { synthesize: vi.fn().mockRejectedValue(new Error("Premium fail")), }); const failingDefault = createMockTtsProvider("default", { synthesize: vi.fn().mockRejectedValue(new Error("Default fail")), }); const fallbackProvider = createMockTtsProvider("fallback"); const ttsProviders = new Map([ ["premium", failingPremium], ["default", failingDefault], ["fallback", fallbackProvider], ]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); const result = await service.synthesize("Hello", { tier: "premium" }); expect(failingPremium.synthesize).toHaveBeenCalled(); expect(failingDefault.synthesize).toHaveBeenCalled(); expect(fallbackProvider.synthesize).toHaveBeenCalled(); expect(result.tier).toBe("fallback"); }); it("should throw ServiceUnavailableException when all tiers fail", async () => { const failingDefault = createMockTtsProvider("default", { synthesize: vi.fn().mockRejectedValue(new Error("Default fail")), }); const failingFallback = createMockTtsProvider("fallback", { synthesize: vi.fn().mockRejectedValue(new Error("Fallback fail")), }); const ttsProviders = new Map([ ["default", failingDefault], ["fallback", failingFallback], ]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); await expect(service.synthesize("Hello")).rejects.toThrow(ServiceUnavailableException); }); it("should skip unavailable tiers in fallback chain", async () => { // premium requested, but only fallback registered (no default) const failingPremium = createMockTtsProvider("premium", { synthesize: vi.fn().mockRejectedValue(new Error("Premium fail")), }); const fallbackProvider = createMockTtsProvider("fallback"); const config = createTestConfig(); config.tts.default.enabled = false; const ttsProviders = new Map([ ["premium", failingPremium], ["fallback", fallbackProvider], ]); const module = await createTestModule({ ttsProviders, config }); const service = module.get(SpeechService); const result = await service.synthesize("Hello", { tier: "premium" }); expect(result.tier).toBe("fallback"); }); }); // ========================================== // listVoices() // ========================================== describe("listVoices", () => { it("should aggregate voices from all registered TTS providers", async () => { const defaultProvider = createMockTtsProvider("default", { listVoices: vi.fn().mockResolvedValue([ { id: "voice-1", name: "Voice 1", tier: "default" as SpeechTier, isDefault: true }, { id: "voice-2", name: "Voice 2", tier: "default" as SpeechTier }, ]), }); const premiumProvider = createMockTtsProvider("premium", { listVoices: vi .fn() .mockResolvedValue([ { id: "voice-3", name: "Voice 3", tier: "premium" as SpeechTier, isDefault: true }, ]), }); const ttsProviders = new Map([ ["default", defaultProvider], ["premium", premiumProvider], ]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); const voices = await service.listVoices(); expect(voices).toHaveLength(3); expect(voices.map((v) => v.id)).toEqual(["voice-1", "voice-2", "voice-3"]); }); it("should filter voices by tier when specified", async () => { const defaultProvider = createMockTtsProvider("default", { listVoices: vi .fn() .mockResolvedValue([{ id: "voice-1", name: "Voice 1", tier: "default" as SpeechTier }]), }); const premiumProvider = createMockTtsProvider("premium", { listVoices: vi .fn() .mockResolvedValue([{ id: "voice-2", name: "Voice 2", tier: "premium" as SpeechTier }]), }); const ttsProviders = new Map([ ["default", defaultProvider], ["premium", premiumProvider], ]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); const voices = await service.listVoices("premium"); expect(voices).toHaveLength(1); expect(voices[0].id).toBe("voice-2"); // Only the premium provider should have been called expect(premiumProvider.listVoices).toHaveBeenCalled(); expect(defaultProvider.listVoices).not.toHaveBeenCalled(); }); it("should return empty array when no TTS providers are registered", async () => { const module = await createTestModule({ ttsProviders: new Map() }); const service = module.get(SpeechService); const voices = await service.listVoices(); expect(voices).toEqual([]); }); it("should return empty array when requested tier has no provider", async () => { const defaultProvider = createMockTtsProvider("default"); const ttsProviders = new Map([["default", defaultProvider]]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); const voices = await service.listVoices("premium"); expect(voices).toEqual([]); }); }); // ========================================== // isSTTAvailable / isTTSAvailable // ========================================== describe("availability checks", () => { it("should report STT as available when enabled and provider registered", async () => { const module = await createTestModule({ sttProvider: createMockSttProvider(), }); const service = module.get(SpeechService); expect(service.isSTTAvailable()).toBe(true); }); it("should report STT as unavailable when disabled in config", async () => { const config = createTestConfig(); config.stt.enabled = false; const module = await createTestModule({ sttProvider: createMockSttProvider(), config, }); const service = module.get(SpeechService); expect(service.isSTTAvailable()).toBe(false); }); it("should report STT as unavailable when no provider registered", async () => { const module = await createTestModule({ sttProvider: null }); const service = module.get(SpeechService); expect(service.isSTTAvailable()).toBe(false); }); it("should report TTS as available when at least one tier is enabled with a provider", async () => { const ttsProviders = new Map([ ["default", createMockTtsProvider("default")], ]); const module = await createTestModule({ ttsProviders }); const service = module.get(SpeechService); expect(service.isTTSAvailable()).toBe(true); }); it("should report TTS as unavailable when no providers registered", async () => { const config = createTestConfig(); config.tts.default.enabled = false; config.tts.premium.enabled = false; config.tts.fallback.enabled = false; const module = await createTestModule({ ttsProviders: new Map(), config }); const service = module.get(SpeechService); expect(service.isTTSAvailable()).toBe(false); }); }); });