Files
stack/apps/api/src/speech/speech.service.spec.ts
Jason Woltje c40373fa3b
All checks were successful
ci/woodpecker/push/api Pipeline was successful
feat(#389): create SpeechModule with provider abstraction layer
Add SpeechModule with provider interfaces and service skeleton for
multi-tier TTS fallback (premium -> default -> fallback) and STT
transcription support. Includes 27 unit tests covering provider
selection, fallback logic, and availability checks.

- ISTTProvider interface with transcribe/isHealthy methods
- ITTSProvider interface with synthesize/listVoices/isHealthy methods
- Shared types: SpeechTier, TranscriptionResult, SynthesisResult, etc.
- SpeechService with graceful TTS fallback chain
- NestJS injection tokens (STT_PROVIDER, TTS_PROVIDERS)
- SpeechModule registered in AppModule
- ConfigModule integration via speechConfig registerAs factory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:09:45 -06:00

542 lines
19 KiB
TypeScript

/**
* 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>): 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>): 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<typeof 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-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<typeof speechConfig>;
}
// ==========================================
// Test helper: create testing module
// ==========================================
async function createTestModule(options: {
sttProvider?: ISTTProvider | null;
ttsProviders?: Map<SpeechTier, ITTSProvider>;
config?: ReturnType<typeof speechConfig>;
}): Promise<TestingModule> {
const config = options.config ?? createTestConfig();
const ttsProviders = options.ttsProviders ?? new Map<SpeechTier, ITTSProvider>();
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>(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>(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>(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>(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>(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>(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>(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<SpeechTier, ITTSProvider>([
["default", defaultProvider],
["premium", premiumProvider],
["fallback", fallbackProvider],
]);
const module = await createTestModule({ ttsProviders });
service = module.get<SpeechService>(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>(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>(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<SpeechTier, ITTSProvider>([
["premium", failingPremium],
["default", defaultProvider],
]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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<SpeechTier, ITTSProvider>([
["default", failingDefault],
["fallback", fallbackProvider],
]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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<SpeechTier, ITTSProvider>([
["premium", failingPremium],
["default", failingDefault],
["fallback", fallbackProvider],
]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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<SpeechTier, ITTSProvider>([
["default", failingDefault],
["fallback", failingFallback],
]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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<SpeechTier, ITTSProvider>([
["premium", failingPremium],
["fallback", fallbackProvider],
]);
const module = await createTestModule({ ttsProviders, config });
const service = module.get<SpeechService>(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<SpeechTier, ITTSProvider>([
["default", defaultProvider],
["premium", premiumProvider],
]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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<SpeechTier, ITTSProvider>([
["default", defaultProvider],
["premium", premiumProvider],
]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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>(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<SpeechTier, ITTSProvider>([["default", defaultProvider]]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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>(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>(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>(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<SpeechTier, ITTSProvider>([
["default", createMockTtsProvider("default")],
]);
const module = await createTestModule({ ttsProviders });
const service = module.get<SpeechService>(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>(SpeechService);
expect(service.isTTSAvailable()).toBe(false);
});
});
});