All checks were successful
ci/woodpecker/push/api Pipeline was successful
Add SpeechConfig with typed configuration and startup validation for STT (Whisper/Speaches), TTS default (Kokoro), TTS premium (Chatterbox), and TTS fallback (Piper/OpenedAI). Includes registerAs factory for NestJS ConfigModule integration, .env.example documentation, and 51 unit tests covering all validation paths. Refs #401
459 lines
17 KiB
TypeScript
459 lines
17 KiB
TypeScript
/**
|
|
* Speech Configuration Tests
|
|
*
|
|
* Issue #401: Tests for speech services environment variable validation
|
|
* Tests cover STT, TTS (default, premium, fallback), and speech limits configuration.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
isSttEnabled,
|
|
isTtsEnabled,
|
|
isTtsPremiumEnabled,
|
|
isTtsFallbackEnabled,
|
|
validateSpeechConfig,
|
|
getSpeechConfig,
|
|
type SpeechConfig,
|
|
} from "./speech.config";
|
|
|
|
describe("speech.config", () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
// Clear all speech-related env vars before each test
|
|
delete process.env.STT_ENABLED;
|
|
delete process.env.STT_BASE_URL;
|
|
delete process.env.STT_MODEL;
|
|
delete process.env.STT_LANGUAGE;
|
|
delete process.env.TTS_ENABLED;
|
|
delete process.env.TTS_DEFAULT_URL;
|
|
delete process.env.TTS_DEFAULT_VOICE;
|
|
delete process.env.TTS_DEFAULT_FORMAT;
|
|
delete process.env.TTS_PREMIUM_ENABLED;
|
|
delete process.env.TTS_PREMIUM_URL;
|
|
delete process.env.TTS_FALLBACK_ENABLED;
|
|
delete process.env.TTS_FALLBACK_URL;
|
|
delete process.env.SPEECH_MAX_UPLOAD_SIZE;
|
|
delete process.env.SPEECH_MAX_DURATION_SECONDS;
|
|
delete process.env.SPEECH_MAX_TEXT_LENGTH;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
// ==========================================
|
|
// STT enabled check
|
|
// ==========================================
|
|
describe("isSttEnabled", () => {
|
|
it("should return false when STT_ENABLED is not set", () => {
|
|
expect(isSttEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return false when STT_ENABLED is 'false'", () => {
|
|
process.env.STT_ENABLED = "false";
|
|
expect(isSttEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return false when STT_ENABLED is '0'", () => {
|
|
process.env.STT_ENABLED = "0";
|
|
expect(isSttEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return false when STT_ENABLED is empty string", () => {
|
|
process.env.STT_ENABLED = "";
|
|
expect(isSttEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return true when STT_ENABLED is 'true'", () => {
|
|
process.env.STT_ENABLED = "true";
|
|
expect(isSttEnabled()).toBe(true);
|
|
});
|
|
|
|
it("should return true when STT_ENABLED is '1'", () => {
|
|
process.env.STT_ENABLED = "1";
|
|
expect(isSttEnabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// TTS enabled check
|
|
// ==========================================
|
|
describe("isTtsEnabled", () => {
|
|
it("should return false when TTS_ENABLED is not set", () => {
|
|
expect(isTtsEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return false when TTS_ENABLED is 'false'", () => {
|
|
process.env.TTS_ENABLED = "false";
|
|
expect(isTtsEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return true when TTS_ENABLED is 'true'", () => {
|
|
process.env.TTS_ENABLED = "true";
|
|
expect(isTtsEnabled()).toBe(true);
|
|
});
|
|
|
|
it("should return true when TTS_ENABLED is '1'", () => {
|
|
process.env.TTS_ENABLED = "1";
|
|
expect(isTtsEnabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// TTS premium enabled check
|
|
// ==========================================
|
|
describe("isTtsPremiumEnabled", () => {
|
|
it("should return false when TTS_PREMIUM_ENABLED is not set", () => {
|
|
expect(isTtsPremiumEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return false when TTS_PREMIUM_ENABLED is 'false'", () => {
|
|
process.env.TTS_PREMIUM_ENABLED = "false";
|
|
expect(isTtsPremiumEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return true when TTS_PREMIUM_ENABLED is 'true'", () => {
|
|
process.env.TTS_PREMIUM_ENABLED = "true";
|
|
expect(isTtsPremiumEnabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// TTS fallback enabled check
|
|
// ==========================================
|
|
describe("isTtsFallbackEnabled", () => {
|
|
it("should return false when TTS_FALLBACK_ENABLED is not set", () => {
|
|
expect(isTtsFallbackEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return false when TTS_FALLBACK_ENABLED is 'false'", () => {
|
|
process.env.TTS_FALLBACK_ENABLED = "false";
|
|
expect(isTtsFallbackEnabled()).toBe(false);
|
|
});
|
|
|
|
it("should return true when TTS_FALLBACK_ENABLED is 'true'", () => {
|
|
process.env.TTS_FALLBACK_ENABLED = "true";
|
|
expect(isTtsFallbackEnabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// validateSpeechConfig
|
|
// ==========================================
|
|
describe("validateSpeechConfig", () => {
|
|
describe("when all services are disabled", () => {
|
|
it("should not throw when no speech services are enabled", () => {
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should not throw when services are explicitly disabled", () => {
|
|
process.env.STT_ENABLED = "false";
|
|
process.env.TTS_ENABLED = "false";
|
|
process.env.TTS_PREMIUM_ENABLED = "false";
|
|
process.env.TTS_FALLBACK_ENABLED = "false";
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("STT validation", () => {
|
|
beforeEach(() => {
|
|
process.env.STT_ENABLED = "true";
|
|
});
|
|
|
|
it("should throw when STT is enabled but STT_BASE_URL is missing", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("STT_BASE_URL");
|
|
expect(() => validateSpeechConfig()).toThrow(
|
|
"STT is enabled (STT_ENABLED=true) but required environment variables are missing"
|
|
);
|
|
});
|
|
|
|
it("should throw when STT_BASE_URL is empty string", () => {
|
|
process.env.STT_BASE_URL = "";
|
|
expect(() => validateSpeechConfig()).toThrow("STT_BASE_URL");
|
|
});
|
|
|
|
it("should throw when STT_BASE_URL is whitespace only", () => {
|
|
process.env.STT_BASE_URL = " ";
|
|
expect(() => validateSpeechConfig()).toThrow("STT_BASE_URL");
|
|
});
|
|
|
|
it("should not throw when STT is enabled and STT_BASE_URL is set", () => {
|
|
process.env.STT_BASE_URL = "http://speaches:8000/v1";
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should suggest disabling STT in error message", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("STT_ENABLED=false");
|
|
});
|
|
});
|
|
|
|
describe("TTS default validation", () => {
|
|
beforeEach(() => {
|
|
process.env.TTS_ENABLED = "true";
|
|
});
|
|
|
|
it("should throw when TTS is enabled but TTS_DEFAULT_URL is missing", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_DEFAULT_URL");
|
|
expect(() => validateSpeechConfig()).toThrow(
|
|
"TTS is enabled (TTS_ENABLED=true) but required environment variables are missing"
|
|
);
|
|
});
|
|
|
|
it("should throw when TTS_DEFAULT_URL is empty string", () => {
|
|
process.env.TTS_DEFAULT_URL = "";
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_DEFAULT_URL");
|
|
});
|
|
|
|
it("should not throw when TTS is enabled and TTS_DEFAULT_URL is set", () => {
|
|
process.env.TTS_DEFAULT_URL = "http://kokoro-tts:8880/v1";
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should suggest disabling TTS in error message", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_ENABLED=false");
|
|
});
|
|
});
|
|
|
|
describe("TTS premium validation", () => {
|
|
beforeEach(() => {
|
|
process.env.TTS_PREMIUM_ENABLED = "true";
|
|
});
|
|
|
|
it("should throw when TTS premium is enabled but TTS_PREMIUM_URL is missing", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_PREMIUM_URL");
|
|
expect(() => validateSpeechConfig()).toThrow(
|
|
"TTS premium is enabled (TTS_PREMIUM_ENABLED=true) but required environment variables are missing"
|
|
);
|
|
});
|
|
|
|
it("should throw when TTS_PREMIUM_URL is empty string", () => {
|
|
process.env.TTS_PREMIUM_URL = "";
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_PREMIUM_URL");
|
|
});
|
|
|
|
it("should not throw when TTS premium is enabled and TTS_PREMIUM_URL is set", () => {
|
|
process.env.TTS_PREMIUM_URL = "http://chatterbox-tts:8881/v1";
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should suggest disabling TTS premium in error message", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_PREMIUM_ENABLED=false");
|
|
});
|
|
});
|
|
|
|
describe("TTS fallback validation", () => {
|
|
beforeEach(() => {
|
|
process.env.TTS_FALLBACK_ENABLED = "true";
|
|
});
|
|
|
|
it("should throw when TTS fallback is enabled but TTS_FALLBACK_URL is missing", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_FALLBACK_URL");
|
|
expect(() => validateSpeechConfig()).toThrow(
|
|
"TTS fallback is enabled (TTS_FALLBACK_ENABLED=true) but required environment variables are missing"
|
|
);
|
|
});
|
|
|
|
it("should throw when TTS_FALLBACK_URL is empty string", () => {
|
|
process.env.TTS_FALLBACK_URL = "";
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_FALLBACK_URL");
|
|
});
|
|
|
|
it("should not throw when TTS fallback is enabled and TTS_FALLBACK_URL is set", () => {
|
|
process.env.TTS_FALLBACK_URL = "http://openedai-speech:8000/v1";
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should suggest disabling TTS fallback in error message", () => {
|
|
expect(() => validateSpeechConfig()).toThrow("TTS_FALLBACK_ENABLED=false");
|
|
});
|
|
});
|
|
|
|
describe("multiple services enabled simultaneously", () => {
|
|
it("should validate all enabled services", () => {
|
|
process.env.STT_ENABLED = "true";
|
|
process.env.TTS_ENABLED = "true";
|
|
// Missing both STT_BASE_URL and TTS_DEFAULT_URL
|
|
|
|
expect(() => validateSpeechConfig()).toThrow("STT_BASE_URL");
|
|
});
|
|
|
|
it("should pass when all enabled services are properly configured", () => {
|
|
process.env.STT_ENABLED = "true";
|
|
process.env.STT_BASE_URL = "http://speaches:8000/v1";
|
|
process.env.TTS_ENABLED = "true";
|
|
process.env.TTS_DEFAULT_URL = "http://kokoro-tts:8880/v1";
|
|
process.env.TTS_PREMIUM_ENABLED = "true";
|
|
process.env.TTS_PREMIUM_URL = "http://chatterbox-tts:8881/v1";
|
|
process.env.TTS_FALLBACK_ENABLED = "true";
|
|
process.env.TTS_FALLBACK_URL = "http://openedai-speech:8000/v1";
|
|
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("limits validation", () => {
|
|
it("should throw when SPEECH_MAX_UPLOAD_SIZE is not a valid number", () => {
|
|
process.env.SPEECH_MAX_UPLOAD_SIZE = "not-a-number";
|
|
expect(() => validateSpeechConfig()).toThrow("SPEECH_MAX_UPLOAD_SIZE");
|
|
expect(() => validateSpeechConfig()).toThrow("must be a positive integer");
|
|
});
|
|
|
|
it("should throw when SPEECH_MAX_UPLOAD_SIZE is negative", () => {
|
|
process.env.SPEECH_MAX_UPLOAD_SIZE = "-100";
|
|
expect(() => validateSpeechConfig()).toThrow("SPEECH_MAX_UPLOAD_SIZE");
|
|
});
|
|
|
|
it("should throw when SPEECH_MAX_UPLOAD_SIZE is zero", () => {
|
|
process.env.SPEECH_MAX_UPLOAD_SIZE = "0";
|
|
expect(() => validateSpeechConfig()).toThrow("SPEECH_MAX_UPLOAD_SIZE");
|
|
});
|
|
|
|
it("should throw when SPEECH_MAX_DURATION_SECONDS is not a valid number", () => {
|
|
process.env.SPEECH_MAX_DURATION_SECONDS = "abc";
|
|
expect(() => validateSpeechConfig()).toThrow("SPEECH_MAX_DURATION_SECONDS");
|
|
});
|
|
|
|
it("should throw when SPEECH_MAX_TEXT_LENGTH is not a valid number", () => {
|
|
process.env.SPEECH_MAX_TEXT_LENGTH = "xyz";
|
|
expect(() => validateSpeechConfig()).toThrow("SPEECH_MAX_TEXT_LENGTH");
|
|
});
|
|
|
|
it("should not throw when limits are valid positive integers", () => {
|
|
process.env.SPEECH_MAX_UPLOAD_SIZE = "50000000";
|
|
process.env.SPEECH_MAX_DURATION_SECONDS = "1200";
|
|
process.env.SPEECH_MAX_TEXT_LENGTH = "8192";
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should not throw when limits are not set (uses defaults)", () => {
|
|
expect(() => validateSpeechConfig()).not.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// getSpeechConfig
|
|
// ==========================================
|
|
describe("getSpeechConfig", () => {
|
|
it("should return default values when no env vars are set", () => {
|
|
const config = getSpeechConfig();
|
|
|
|
expect(config.stt.enabled).toBe(false);
|
|
expect(config.stt.baseUrl).toBe("http://speaches:8000/v1");
|
|
expect(config.stt.model).toBe("Systran/faster-whisper-large-v3-turbo");
|
|
expect(config.stt.language).toBe("en");
|
|
|
|
expect(config.tts.default.enabled).toBe(false);
|
|
expect(config.tts.default.url).toBe("http://kokoro-tts:8880/v1");
|
|
expect(config.tts.default.voice).toBe("af_heart");
|
|
expect(config.tts.default.format).toBe("mp3");
|
|
|
|
expect(config.tts.premium.enabled).toBe(false);
|
|
expect(config.tts.premium.url).toBe("http://chatterbox-tts:8881/v1");
|
|
|
|
expect(config.tts.fallback.enabled).toBe(false);
|
|
expect(config.tts.fallback.url).toBe("http://openedai-speech:8000/v1");
|
|
|
|
expect(config.limits.maxUploadSize).toBe(25000000);
|
|
expect(config.limits.maxDurationSeconds).toBe(600);
|
|
expect(config.limits.maxTextLength).toBe(4096);
|
|
});
|
|
|
|
it("should use custom env var values when set", () => {
|
|
process.env.STT_ENABLED = "true";
|
|
process.env.STT_BASE_URL = "http://custom-stt:9000/v1";
|
|
process.env.STT_MODEL = "custom-model";
|
|
process.env.STT_LANGUAGE = "fr";
|
|
|
|
process.env.TTS_ENABLED = "true";
|
|
process.env.TTS_DEFAULT_URL = "http://custom-tts:9001/v1";
|
|
process.env.TTS_DEFAULT_VOICE = "custom_voice";
|
|
process.env.TTS_DEFAULT_FORMAT = "wav";
|
|
|
|
process.env.TTS_PREMIUM_ENABLED = "true";
|
|
process.env.TTS_PREMIUM_URL = "http://custom-premium:9002/v1";
|
|
|
|
process.env.TTS_FALLBACK_ENABLED = "true";
|
|
process.env.TTS_FALLBACK_URL = "http://custom-fallback:9003/v1";
|
|
|
|
process.env.SPEECH_MAX_UPLOAD_SIZE = "50000000";
|
|
process.env.SPEECH_MAX_DURATION_SECONDS = "1200";
|
|
process.env.SPEECH_MAX_TEXT_LENGTH = "8192";
|
|
|
|
const config = getSpeechConfig();
|
|
|
|
expect(config.stt.enabled).toBe(true);
|
|
expect(config.stt.baseUrl).toBe("http://custom-stt:9000/v1");
|
|
expect(config.stt.model).toBe("custom-model");
|
|
expect(config.stt.language).toBe("fr");
|
|
|
|
expect(config.tts.default.enabled).toBe(true);
|
|
expect(config.tts.default.url).toBe("http://custom-tts:9001/v1");
|
|
expect(config.tts.default.voice).toBe("custom_voice");
|
|
expect(config.tts.default.format).toBe("wav");
|
|
|
|
expect(config.tts.premium.enabled).toBe(true);
|
|
expect(config.tts.premium.url).toBe("http://custom-premium:9002/v1");
|
|
|
|
expect(config.tts.fallback.enabled).toBe(true);
|
|
expect(config.tts.fallback.url).toBe("http://custom-fallback:9003/v1");
|
|
|
|
expect(config.limits.maxUploadSize).toBe(50000000);
|
|
expect(config.limits.maxDurationSeconds).toBe(1200);
|
|
expect(config.limits.maxTextLength).toBe(8192);
|
|
});
|
|
|
|
it("should return typed SpeechConfig object", () => {
|
|
const config: SpeechConfig = getSpeechConfig();
|
|
|
|
// Verify structure matches the SpeechConfig type
|
|
expect(config).toHaveProperty("stt");
|
|
expect(config).toHaveProperty("tts");
|
|
expect(config).toHaveProperty("limits");
|
|
expect(config.tts).toHaveProperty("default");
|
|
expect(config.tts).toHaveProperty("premium");
|
|
expect(config.tts).toHaveProperty("fallback");
|
|
});
|
|
|
|
it("should handle partial env var overrides", () => {
|
|
process.env.STT_ENABLED = "true";
|
|
process.env.STT_BASE_URL = "http://custom-stt:9000/v1";
|
|
// STT_MODEL and STT_LANGUAGE not set, should use defaults
|
|
|
|
const config = getSpeechConfig();
|
|
|
|
expect(config.stt.enabled).toBe(true);
|
|
expect(config.stt.baseUrl).toBe("http://custom-stt:9000/v1");
|
|
expect(config.stt.model).toBe("Systran/faster-whisper-large-v3-turbo");
|
|
expect(config.stt.language).toBe("en");
|
|
});
|
|
|
|
it("should parse numeric limits correctly", () => {
|
|
process.env.SPEECH_MAX_UPLOAD_SIZE = "10000000";
|
|
const config = getSpeechConfig();
|
|
expect(typeof config.limits.maxUploadSize).toBe("number");
|
|
expect(config.limits.maxUploadSize).toBe(10000000);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// registerAs integration
|
|
// ==========================================
|
|
describe("speechConfig (registerAs factory)", () => {
|
|
it("should be importable as a config namespace factory", async () => {
|
|
const { speechConfig } = await import("./speech.config");
|
|
expect(speechConfig).toBeDefined();
|
|
expect(speechConfig.KEY).toBe("CONFIGURATION(speech)");
|
|
});
|
|
|
|
it("should return config object when called", async () => {
|
|
const { speechConfig } = await import("./speech.config");
|
|
const config = speechConfig() as SpeechConfig;
|
|
expect(config).toHaveProperty("stt");
|
|
expect(config).toHaveProperty("tts");
|
|
expect(config).toHaveProperty("limits");
|
|
});
|
|
});
|
|
});
|