/** * SpeechGateway Tests * * Issue #397: WebSocket streaming transcription endpoint tests. * Written FIRST following TDD (Red-Green-Refactor). * * Tests cover: * - Authentication via handshake token * - Session lifecycle: start -> audio chunks -> stop * - Transcription result emission * - Session cleanup on disconnect * - Error handling * - Buffer size limit enforcement */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Socket } from "socket.io"; import { SpeechGateway } from "./speech.gateway"; import { SpeechService } from "./speech.service"; import { AuthService } from "../auth/auth.service"; import { PrismaService } from "../prisma/prisma.service"; import type { SpeechConfig } from "./speech.config"; import type { TranscriptionResult } from "./interfaces/speech-types"; // ========================================== // Test helpers // ========================================== interface AuthenticatedSocket extends Socket { data: { userId?: string; workspaceId?: string; }; } function createMockConfig(): SpeechConfig { 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", format: "mp3" }, premium: { enabled: false, url: "" }, fallback: { enabled: false, url: "" }, }, limits: { maxUploadSize: 25_000_000, maxDurationSeconds: 600, maxTextLength: 4096, }, }; } function createMockSocket(overrides?: Partial): AuthenticatedSocket { return { id: "test-socket-id", join: vi.fn(), leave: vi.fn(), emit: vi.fn(), disconnect: vi.fn(), data: {}, handshake: { auth: { token: "valid-token" }, query: {}, headers: {}, }, ...overrides, } as unknown as AuthenticatedSocket; } function createMockAuthService(): { verifySession: ReturnType; } { return { verifySession: vi.fn().mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }), }; } function createMockPrismaService(): { workspaceMember: { findFirst: ReturnType }; } { return { workspaceMember: { findFirst: vi.fn().mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }), }, }; } function createMockSpeechService(): { transcribe: ReturnType; isSTTAvailable: ReturnType; } { return { transcribe: vi.fn().mockResolvedValue({ text: "Hello world", language: "en", durationSeconds: 2.5, } satisfies TranscriptionResult), isSTTAvailable: vi.fn().mockReturnValue(true), }; } // ========================================== // Tests // ========================================== describe("SpeechGateway", () => { let gateway: SpeechGateway; let mockAuthService: ReturnType; let mockPrismaService: ReturnType; let mockSpeechService: ReturnType; let mockConfig: SpeechConfig; let mockClient: AuthenticatedSocket; beforeEach(() => { mockAuthService = createMockAuthService(); mockPrismaService = createMockPrismaService(); mockSpeechService = createMockSpeechService(); mockConfig = createMockConfig(); mockClient = createMockSocket(); gateway = new SpeechGateway( mockAuthService as unknown as AuthService, mockPrismaService as unknown as PrismaService, mockSpeechService as unknown as SpeechService, mockConfig ); vi.clearAllMocks(); }); // ========================================== // Authentication // ========================================== describe("handleConnection", () => { it("should authenticate client and populate socket data on valid token", async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }); await gateway.handleConnection(mockClient); expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token"); expect(mockClient.data.userId).toBe("user-123"); expect(mockClient.data.workspaceId).toBe("workspace-456"); }); it("should disconnect client without token", async () => { const clientNoToken = createMockSocket({ handshake: { auth: {}, query: {}, headers: {} }, } as Partial); await gateway.handleConnection(clientNoToken); expect(clientNoToken.disconnect).toHaveBeenCalled(); }); it("should disconnect client with invalid token", async () => { mockAuthService.verifySession.mockResolvedValue(null); await gateway.handleConnection(mockClient); expect(mockClient.disconnect).toHaveBeenCalled(); }); it("should disconnect client without workspace access", async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null); await gateway.handleConnection(mockClient); expect(mockClient.disconnect).toHaveBeenCalled(); }); it("should disconnect client when auth throws", async () => { mockAuthService.verifySession.mockRejectedValue(new Error("Auth failure")); await gateway.handleConnection(mockClient); expect(mockClient.disconnect).toHaveBeenCalled(); }); it("should extract token from handshake.query as fallback", async () => { const clientQueryToken = createMockSocket({ handshake: { auth: {}, query: { token: "query-token" }, headers: {}, }, } as Partial); mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }); await gateway.handleConnection(clientQueryToken); expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token"); }); }); // ========================================== // start-transcription // ========================================== describe("handleStartTranscription", () => { beforeEach(async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }); await gateway.handleConnection(mockClient); vi.clearAllMocks(); }); it("should create a transcription session", () => { gateway.handleStartTranscription(mockClient, { language: "en" }); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-started", expect.objectContaining({ sessionId: expect.any(String) }) ); }); it("should create a session with optional language parameter", () => { gateway.handleStartTranscription(mockClient, { language: "fr" }); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-started", expect.objectContaining({ sessionId: expect.any(String) }) ); }); it("should create a session with no options", () => { gateway.handleStartTranscription(mockClient, {}); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-started", expect.objectContaining({ sessionId: expect.any(String) }) ); }); it("should emit error if client is not authenticated", () => { const unauthClient = createMockSocket(); // Not connected through handleConnection, so no userId set gateway.handleStartTranscription(unauthClient, {}); expect(unauthClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.any(String) }) ); }); it("should replace existing session if one already exists", () => { gateway.handleStartTranscription(mockClient, {}); gateway.handleStartTranscription(mockClient, { language: "de" }); // Should have emitted transcription-started twice (no error) const startedCalls = (mockClient.emit as ReturnType).mock.calls.filter( (call: unknown[]) => call[0] === "transcription-started" ); expect(startedCalls).toHaveLength(2); }); }); // ========================================== // audio-chunk // ========================================== describe("handleAudioChunk", () => { beforeEach(async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }); await gateway.handleConnection(mockClient); vi.clearAllMocks(); gateway.handleStartTranscription(mockClient, {}); vi.clearAllMocks(); }); it("should accumulate audio data in the session", () => { const chunk = Buffer.from("audio-data-1"); gateway.handleAudioChunk(mockClient, chunk); // No error emitted const errorCalls = (mockClient.emit as ReturnType).mock.calls.filter( (call: unknown[]) => call[0] === "transcription-error" ); expect(errorCalls).toHaveLength(0); }); it("should accept Uint8Array data and convert to Buffer", () => { const chunk = new Uint8Array([1, 2, 3, 4]); gateway.handleAudioChunk(mockClient, chunk); const errorCalls = (mockClient.emit as ReturnType).mock.calls.filter( (call: unknown[]) => call[0] === "transcription-error" ); expect(errorCalls).toHaveLength(0); }); it("should emit error if no active session exists", () => { const noSessionClient = createMockSocket({ id: "no-session" }); noSessionClient.data = { userId: "user-123", workspaceId: "workspace-456" }; const chunk = Buffer.from("audio-data"); gateway.handleAudioChunk(noSessionClient, chunk); expect(noSessionClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.any(String) }) ); }); it("should emit error if client is not authenticated", () => { const unauthClient = createMockSocket({ id: "unauth" }); // Not authenticated const chunk = Buffer.from("audio-data"); gateway.handleAudioChunk(unauthClient, chunk); expect(unauthClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.any(String) }) ); }); it("should emit error when buffer size exceeds max upload size", () => { // Set a very small max upload size const smallConfig = createMockConfig(); smallConfig.limits.maxUploadSize = 10; const limitedGateway = new SpeechGateway( mockAuthService as unknown as AuthService, mockPrismaService as unknown as PrismaService, mockSpeechService as unknown as SpeechService, smallConfig ); // We need to manually set up the authenticated client in the new gateway const limitedClient = createMockSocket({ id: "limited-client" }); limitedClient.data = { userId: "user-123", workspaceId: "workspace-456" }; // Start session directly (since handleConnection populates data) limitedGateway.handleStartTranscription(limitedClient, {}); vi.clearAllMocks(); // Send a chunk that exceeds the limit const largeChunk = Buffer.alloc(20, "a"); limitedGateway.handleAudioChunk(limitedClient, largeChunk); expect(limitedClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.stringContaining("exceeds") }) ); }); it("should emit error when accumulated buffer size exceeds max upload size", () => { const smallConfig = createMockConfig(); smallConfig.limits.maxUploadSize = 15; const limitedGateway = new SpeechGateway( mockAuthService as unknown as AuthService, mockPrismaService as unknown as PrismaService, mockSpeechService as unknown as SpeechService, smallConfig ); const limitedClient = createMockSocket({ id: "limited-client-2" }); limitedClient.data = { userId: "user-123", workspaceId: "workspace-456" }; limitedGateway.handleStartTranscription(limitedClient, {}); vi.clearAllMocks(); // Send two chunks that together exceed the limit const chunk1 = Buffer.alloc(10, "a"); const chunk2 = Buffer.alloc(10, "b"); limitedGateway.handleAudioChunk(limitedClient, chunk1); limitedGateway.handleAudioChunk(limitedClient, chunk2); expect(limitedClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.stringContaining("exceeds") }) ); }); }); // ========================================== // stop-transcription // ========================================== describe("handleStopTranscription", () => { beforeEach(async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }); await gateway.handleConnection(mockClient); vi.clearAllMocks(); }); it("should transcribe accumulated audio and emit final result", async () => { gateway.handleStartTranscription(mockClient, { language: "en" }); const chunk1 = Buffer.from("audio-part-1"); const chunk2 = Buffer.from("audio-part-2"); gateway.handleAudioChunk(mockClient, chunk1); gateway.handleAudioChunk(mockClient, chunk2); vi.clearAllMocks(); const expectedResult: TranscriptionResult = { text: "Hello world", language: "en", durationSeconds: 2.5, }; mockSpeechService.transcribe.mockResolvedValue(expectedResult); await gateway.handleStopTranscription(mockClient); // Should have called transcribe with concatenated buffer expect(mockSpeechService.transcribe).toHaveBeenCalledWith( expect.any(Buffer), expect.objectContaining({}) ); // Should emit transcription-final expect(mockClient.emit).toHaveBeenCalledWith( "transcription-final", expect.objectContaining({ text: "Hello world" }) ); }); it("should pass language option to SpeechService.transcribe", async () => { gateway.handleStartTranscription(mockClient, { language: "fr" }); gateway.handleAudioChunk(mockClient, Buffer.from("audio")); vi.clearAllMocks(); await gateway.handleStopTranscription(mockClient); expect(mockSpeechService.transcribe).toHaveBeenCalledWith( expect.any(Buffer), expect.objectContaining({ language: "fr" }) ); }); it("should clean up session after stop", async () => { gateway.handleStartTranscription(mockClient, {}); gateway.handleAudioChunk(mockClient, Buffer.from("audio")); await gateway.handleStopTranscription(mockClient); vi.clearAllMocks(); // Sending more audio after stop should error (no session) gateway.handleAudioChunk(mockClient, Buffer.from("more-audio")); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.any(String) }) ); }); it("should emit transcription-error when transcription fails", async () => { gateway.handleStartTranscription(mockClient, {}); gateway.handleAudioChunk(mockClient, Buffer.from("audio")); vi.clearAllMocks(); mockSpeechService.transcribe.mockRejectedValue(new Error("STT service down")); await gateway.handleStopTranscription(mockClient); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.stringContaining("STT service down") }) ); }); it("should emit error if no active session exists", async () => { await gateway.handleStopTranscription(mockClient); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.any(String) }) ); }); it("should emit error if client is not authenticated", async () => { const unauthClient = createMockSocket({ id: "unauth-stop" }); await gateway.handleStopTranscription(unauthClient); expect(unauthClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.any(String) }) ); }); it("should emit error when stopping with no audio chunks received", async () => { gateway.handleStartTranscription(mockClient, {}); vi.clearAllMocks(); await gateway.handleStopTranscription(mockClient); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.stringContaining("No audio") }) ); }); }); // ========================================== // handleDisconnect // ========================================== describe("handleDisconnect", () => { beforeEach(async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }); await gateway.handleConnection(mockClient); vi.clearAllMocks(); }); it("should clean up active session on disconnect", () => { gateway.handleStartTranscription(mockClient, {}); gateway.handleAudioChunk(mockClient, Buffer.from("audio")); gateway.handleDisconnect(mockClient); // Session should be gone. Verify by trying to add a chunk to a new // socket with the same ID (should error since session was cleaned up). const newClient = createMockSocket({ id: mockClient.id }); newClient.data = { userId: "user-123", workspaceId: "workspace-456" }; gateway.handleAudioChunk(newClient, Buffer.from("more")); expect(newClient.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.any(String) }) ); }); it("should not throw when disconnecting client without active session", () => { expect(() => gateway.handleDisconnect(mockClient)).not.toThrow(); }); it("should not throw when disconnecting unauthenticated client", () => { const unauthClient = createMockSocket({ id: "unauth-disconnect" }); expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow(); }); }); // ========================================== // Edge cases // ========================================== describe("edge cases", () => { beforeEach(async () => { mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" }, session: { id: "session-123" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-123", workspaceId: "workspace-456", role: "MEMBER", }); await gateway.handleConnection(mockClient); vi.clearAllMocks(); }); it("should handle multiple start-stop cycles for the same client", async () => { // First cycle gateway.handleStartTranscription(mockClient, {}); gateway.handleAudioChunk(mockClient, Buffer.from("cycle-1")); await gateway.handleStopTranscription(mockClient); vi.clearAllMocks(); // Second cycle gateway.handleStartTranscription(mockClient, { language: "de" }); gateway.handleAudioChunk(mockClient, Buffer.from("cycle-2")); await gateway.handleStopTranscription(mockClient); expect(mockSpeechService.transcribe).toHaveBeenCalledTimes(1); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-final", expect.objectContaining({ text: "Hello world" }) ); }); it("should isolate sessions between different clients", async () => { const client2 = createMockSocket({ id: "client-2" }); client2.data = { userId: "user-456", workspaceId: "workspace-789" }; // Client 2 also needs to be "connected" mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-456" }, session: { id: "session-456" }, }); mockPrismaService.workspaceMember.findFirst.mockResolvedValue({ userId: "user-456", workspaceId: "workspace-789", role: "MEMBER", }); await gateway.handleConnection(client2); vi.clearAllMocks(); // Start sessions for both clients gateway.handleStartTranscription(mockClient, {}); gateway.handleStartTranscription(client2, {}); // Send audio to client 1 only gateway.handleAudioChunk(mockClient, Buffer.from("audio-for-client-1")); // Stop client 2 (no audio) await gateway.handleStopTranscription(client2); // Client 2 should get an error (no audio received) expect(client2.emit).toHaveBeenCalledWith( "transcription-error", expect.objectContaining({ message: expect.stringContaining("No audio") }) ); vi.clearAllMocks(); // Stop client 1 (has audio) -- should succeed await gateway.handleStopTranscription(mockClient); expect(mockSpeechService.transcribe).toHaveBeenCalled(); expect(mockClient.emit).toHaveBeenCalledWith( "transcription-final", expect.objectContaining({ text: "Hello world" }) ); }); }); });