feat(#401): add speech services config and env vars
All checks were successful
ci/woodpecker/push/api Pipeline was successful
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
This commit is contained in:
458
apps/api/src/speech/speech.config.spec.ts
Normal file
458
apps/api/src/speech/speech.config.spec.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user