import { describe, it, expect, beforeEach, vi } from "vitest"; import { StreamableFile, ServiceUnavailableException } from "@nestjs/common"; import { SpeechController } from "./speech.controller"; import { SpeechService } from "./speech.service"; import type { TranscribeDto } from "./dto/transcribe.dto"; import type { SynthesizeDto } from "./dto/synthesize.dto"; import type { TranscriptionResult, SynthesisResult, VoiceInfo } from "./interfaces/speech-types"; describe("SpeechController", () => { let controller: SpeechController; let service: SpeechService; const mockSpeechService = { transcribe: vi.fn(), synthesize: vi.fn(), listVoices: vi.fn(), isSTTAvailable: vi.fn(), isTTSAvailable: vi.fn(), }; const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; const mockUser = { id: mockUserId, email: "test@example.com", name: "Test User", workspaceId: mockWorkspaceId, }; const mockFile: Express.Multer.File = { buffer: Buffer.from("fake-audio-data"), mimetype: "audio/wav", size: 1024, originalname: "test.wav", fieldname: "file", encoding: "7bit", stream: null as never, destination: "", filename: "", path: "", }; const mockTranscriptionResult: TranscriptionResult = { text: "Hello, world!", language: "en", durationSeconds: 2.5, confidence: 0.95, }; beforeEach(() => { service = mockSpeechService as unknown as SpeechService; controller = new SpeechController(service); vi.clearAllMocks(); }); it("should be defined", () => { expect(controller).toBeDefined(); }); describe("transcribe", () => { it("should transcribe audio file and return data wrapper", async () => { mockSpeechService.transcribe.mockResolvedValue(mockTranscriptionResult); const dto: TranscribeDto = {}; const result = await controller.transcribe(mockFile, dto, mockWorkspaceId, mockUser); expect(result).toEqual({ data: mockTranscriptionResult }); expect(mockSpeechService.transcribe).toHaveBeenCalledWith(mockFile.buffer, { mimeType: "audio/wav", }); }); it("should pass language override from DTO to service", async () => { mockSpeechService.transcribe.mockResolvedValue(mockTranscriptionResult); const dto: TranscribeDto = { language: "fr" }; await controller.transcribe(mockFile, dto, mockWorkspaceId, mockUser); expect(mockSpeechService.transcribe).toHaveBeenCalledWith(mockFile.buffer, { language: "fr", mimeType: "audio/wav", }); }); it("should pass model override from DTO to service", async () => { mockSpeechService.transcribe.mockResolvedValue(mockTranscriptionResult); const dto: TranscribeDto = { model: "whisper-large-v3" }; await controller.transcribe(mockFile, dto, mockWorkspaceId, mockUser); expect(mockSpeechService.transcribe).toHaveBeenCalledWith(mockFile.buffer, { model: "whisper-large-v3", mimeType: "audio/wav", }); }); it("should pass all DTO options to service", async () => { mockSpeechService.transcribe.mockResolvedValue(mockTranscriptionResult); const dto: TranscribeDto = { language: "de", model: "whisper-large-v3", prompt: "Meeting notes", temperature: 0.5, }; await controller.transcribe(mockFile, dto, mockWorkspaceId, mockUser); expect(mockSpeechService.transcribe).toHaveBeenCalledWith(mockFile.buffer, { language: "de", model: "whisper-large-v3", prompt: "Meeting notes", temperature: 0.5, mimeType: "audio/wav", }); }); it("should propagate service errors", async () => { mockSpeechService.transcribe.mockRejectedValue(new Error("STT unavailable")); const dto: TranscribeDto = {}; await expect(controller.transcribe(mockFile, dto, mockWorkspaceId, mockUser)).rejects.toThrow( "STT unavailable" ); }); }); describe("health", () => { it("should return health status with both providers available", async () => { mockSpeechService.isSTTAvailable.mockReturnValue(true); mockSpeechService.isTTSAvailable.mockReturnValue(true); const result = await controller.health(mockWorkspaceId); expect(result).toEqual({ data: { stt: { available: true }, tts: { available: true }, }, }); }); it("should return health status with STT unavailable", async () => { mockSpeechService.isSTTAvailable.mockReturnValue(false); mockSpeechService.isTTSAvailable.mockReturnValue(true); const result = await controller.health(mockWorkspaceId); expect(result).toEqual({ data: { stt: { available: false }, tts: { available: true }, }, }); }); it("should return health status with TTS unavailable", async () => { mockSpeechService.isSTTAvailable.mockReturnValue(true); mockSpeechService.isTTSAvailable.mockReturnValue(false); const result = await controller.health(mockWorkspaceId); expect(result).toEqual({ data: { stt: { available: true }, tts: { available: false }, }, }); }); it("should return health status with both providers unavailable", async () => { mockSpeechService.isSTTAvailable.mockReturnValue(false); mockSpeechService.isTTSAvailable.mockReturnValue(false); const result = await controller.health(mockWorkspaceId); expect(result).toEqual({ data: { stt: { available: false }, tts: { available: false }, }, }); }); }); // ============================================== // POST /api/speech/synthesize (Issue #396) // ============================================== describe("synthesize", () => { const mockAudioBuffer = Buffer.from("fake-audio-data"); const mockSynthesisResult: SynthesisResult = { audio: mockAudioBuffer, format: "mp3", voice: "af_heart", tier: "default", durationSeconds: 2.5, }; it("should synthesize text and return a StreamableFile", async () => { const dto: SynthesizeDto = { text: "Hello world" }; mockSpeechService.synthesize.mockResolvedValue(mockSynthesisResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); expect(mockSpeechService.synthesize).toHaveBeenCalledWith("Hello world", {}); expect(result).toBeInstanceOf(StreamableFile); }); it("should pass voice, speed, format, and tier options to the service", async () => { const dto: SynthesizeDto = { text: "Test with options", voice: "af_heart", speed: 1.5, format: "wav", tier: "premium", }; const wavResult: SynthesisResult = { audio: mockAudioBuffer, format: "wav", voice: "af_heart", tier: "premium", }; mockSpeechService.synthesize.mockResolvedValue(wavResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); expect(mockSpeechService.synthesize).toHaveBeenCalledWith("Test with options", { voice: "af_heart", speed: 1.5, format: "wav", tier: "premium", }); expect(result).toBeInstanceOf(StreamableFile); }); it("should set correct Content-Type for mp3 format", async () => { const dto: SynthesizeDto = { text: "Hello", format: "mp3" }; mockSpeechService.synthesize.mockResolvedValue(mockSynthesisResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); expect(result).toBeInstanceOf(StreamableFile); const headers = result.getHeaders(); expect(headers.type).toBe("audio/mpeg"); }); it("should set correct Content-Type for wav format", async () => { const dto: SynthesizeDto = { text: "Hello" }; const wavResult: SynthesisResult = { ...mockSynthesisResult, format: "wav" }; mockSpeechService.synthesize.mockResolvedValue(wavResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.type).toBe("audio/wav"); }); it("should set correct Content-Type for opus format", async () => { const dto: SynthesizeDto = { text: "Hello" }; const opusResult: SynthesisResult = { ...mockSynthesisResult, format: "opus" }; mockSpeechService.synthesize.mockResolvedValue(opusResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.type).toBe("audio/opus"); }); it("should set correct Content-Type for flac format", async () => { const dto: SynthesizeDto = { text: "Hello" }; const flacResult: SynthesisResult = { ...mockSynthesisResult, format: "flac" }; mockSpeechService.synthesize.mockResolvedValue(flacResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.type).toBe("audio/flac"); }); it("should set correct Content-Type for aac format", async () => { const dto: SynthesizeDto = { text: "Hello" }; const aacResult: SynthesisResult = { ...mockSynthesisResult, format: "aac" }; mockSpeechService.synthesize.mockResolvedValue(aacResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.type).toBe("audio/aac"); }); it("should set correct Content-Type for pcm format", async () => { const dto: SynthesizeDto = { text: "Hello" }; const pcmResult: SynthesisResult = { ...mockSynthesisResult, format: "pcm" }; mockSpeechService.synthesize.mockResolvedValue(pcmResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.type).toBe("audio/pcm"); }); it("should set Content-Disposition header for download with correct extension", async () => { const dto: SynthesizeDto = { text: "Hello" }; mockSpeechService.synthesize.mockResolvedValue(mockSynthesisResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.disposition).toContain("attachment"); expect(headers.disposition).toContain("speech.mp3"); }); it("should set Content-Disposition with correct file extension for wav", async () => { const dto: SynthesizeDto = { text: "Hello" }; const wavResult: SynthesisResult = { ...mockSynthesisResult, format: "wav" }; mockSpeechService.synthesize.mockResolvedValue(wavResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.disposition).toContain("speech.wav"); }); it("should set Content-Length header based on audio buffer size", async () => { const dto: SynthesizeDto = { text: "Hello" }; mockSpeechService.synthesize.mockResolvedValue(mockSynthesisResult); const result = await controller.synthesize(dto, mockWorkspaceId, mockUser); const headers = result.getHeaders(); expect(headers.length).toBe(mockAudioBuffer.length); }); it("should propagate ServiceUnavailableException from service", async () => { const dto: SynthesizeDto = { text: "Hello" }; mockSpeechService.synthesize.mockRejectedValue( new ServiceUnavailableException("No TTS providers are available") ); await expect(controller.synthesize(dto, mockWorkspaceId, mockUser)).rejects.toThrow( ServiceUnavailableException ); }); }); // ============================================== // GET /api/speech/voices (Issue #396) // ============================================== describe("getVoices", () => { const mockVoices: VoiceInfo[] = [ { id: "af_heart", name: "Heart", language: "en", tier: "default", isDefault: true, }, { id: "af_sky", name: "Sky", language: "en", tier: "default", isDefault: false, }, { id: "chatterbox-voice", name: "Chatterbox Default", language: "en", tier: "premium", isDefault: true, }, ]; it("should return all voices when no tier filter is provided", async () => { mockSpeechService.listVoices.mockResolvedValue(mockVoices); const result = await controller.getVoices(mockWorkspaceId); expect(mockSpeechService.listVoices).toHaveBeenCalledWith(undefined); expect(result).toEqual({ data: mockVoices }); }); it("should filter voices by default tier", async () => { const defaultVoices = mockVoices.filter((v) => v.tier === "default"); mockSpeechService.listVoices.mockResolvedValue(defaultVoices); const result = await controller.getVoices(mockWorkspaceId, "default"); expect(mockSpeechService.listVoices).toHaveBeenCalledWith("default"); expect(result).toEqual({ data: defaultVoices }); }); it("should filter voices by premium tier", async () => { const premiumVoices = mockVoices.filter((v) => v.tier === "premium"); mockSpeechService.listVoices.mockResolvedValue(premiumVoices); const result = await controller.getVoices(mockWorkspaceId, "premium"); expect(mockSpeechService.listVoices).toHaveBeenCalledWith("premium"); expect(result).toEqual({ data: premiumVoices }); }); it("should return empty array when no voices are available", async () => { mockSpeechService.listVoices.mockResolvedValue([]); const result = await controller.getVoices(mockWorkspaceId); expect(result).toEqual({ data: [] }); }); it("should return empty array when filtering by tier with no matching voices", async () => { mockSpeechService.listVoices.mockResolvedValue([]); const result = await controller.getVoices(mockWorkspaceId, "fallback"); expect(mockSpeechService.listVoices).toHaveBeenCalledWith("fallback"); expect(result).toEqual({ data: [] }); }); }); });