feat(#401): add speech services config and env vars
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:
2026-02-15 02:03:21 -06:00
parent fb53272fa9
commit 4cc43bece6
4 changed files with 814 additions and 6 deletions

View 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");
});
});
});