feat(#389): create SpeechModule with provider abstraction layer
All checks were successful
ci/woodpecker/push/api Pipeline was successful
All checks were successful
ci/woodpecker/push/api Pipeline was successful
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>
This commit is contained in:
541
apps/api/src/speech/speech.service.spec.ts
Normal file
541
apps/api/src/speech/speech.service.spec.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user