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>
542 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|