Files
hermes-ui/tests/server/tts-synthesize-controller.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

961 lines
33 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
function createMockCtx(body: Record<string, any> = {}) {
const headers: Record<string, string> = {}
let requestAbortHandler: (() => void) | undefined
let responseCloseHandler: (() => void) | undefined
const ctx: any = {
request: { body },
req: {
on: vi.fn((event: string, handler: () => void) => {
if (event === 'aborted') {
requestAbortHandler = handler
}
}),
},
res: {
writableEnded: false,
on: vi.fn((event: string, handler: () => void) => {
if (event === 'close') {
responseCloseHandler = handler
}
}),
},
status: 200,
body: undefined,
set: vi.fn((name: string, value: string) => {
headers[name] = value
}),
}
return {
ctx,
headers,
emitRequestAborted() {
requestAbortHandler?.()
},
emitResponseClose({ writableEnded = false } = {}) {
ctx.res.writableEnded = writableEnded
responseCloseHandler?.()
},
}
}
describe('getTtsProvider', () => {
it('returns expected providers and undefined for unknown ids', async () => {
const { getTtsProvider } = await import('../../packages/server/src/services/hermes/tts-providers')
const { edgeTtsProvider } = await import('../../packages/server/src/services/hermes/tts-providers/edge')
const { customTtsProvider, openaiTtsProvider } = await import('../../packages/server/src/services/hermes/tts-providers/openai')
const { mimoTtsProvider } = await import('../../packages/server/src/services/hermes/tts-providers/mimo')
const { doubaoTtsProvider } = await import('../../packages/server/src/services/hermes/tts-providers/doubao')
expect(getTtsProvider('edge')).toBe(edgeTtsProvider)
expect(getTtsProvider('openai')).toBe(openaiTtsProvider)
expect(getTtsProvider('custom')).toBe(customTtsProvider)
expect(getTtsProvider('mimo')).toBe(mimoTtsProvider)
expect(getTtsProvider('doubao')).toBe(doubaoTtsProvider)
expect(getTtsProvider('unknown')).toBeUndefined()
})
})
describe('tts synthesize controller', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.doUnmock('../../packages/server/src/services/hermes/tts-providers')
vi.doUnmock('../../packages/server/src/controllers/hermes/tts')
vi.doUnmock('../../packages/server/src/db/index')
})
it('returns 400 for an unknown provider', async () => {
const getTtsProvider = vi.fn(() => undefined)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({ provider: 'nope', text: 'hello' })
await ctrl.synthesize(ctx)
expect(getTtsProvider).toHaveBeenCalledWith('nope')
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'unknown TTS provider' })
})
it('saves TTS settings when the legacy provider table has no unique index', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
try {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
db.exec('DROP INDEX IF EXISTS idx_tts_provider_settings_user_provider')
db.exec('DROP TABLE tts_provider_settings')
db.exec(`
CREATE TABLE tts_provider_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile TEXT NOT NULL DEFAULT 'default',
provider TEXT NOT NULL,
settings_json TEXT NOT NULL DEFAULT '{}',
secrets_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
db.exec('DROP TABLE tts_user_settings')
db.exec(`
CREATE TABLE tts_user_settings (
profile TEXT PRIMARY KEY DEFAULT 'default',
active_provider TEXT NOT NULL DEFAULT 'edge',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
settings: {
model: 'tts-1',
voice: 'alloy',
},
secrets: {
apiKey: 'server-secret',
},
})
ctx.state = { user: { id: 7 } }
ctx.params = { provider: 'openai' }
ctx.query = {}
ctx.get = vi.fn(() => '')
await ctrl.saveSettings(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.setting).toMatchObject({
provider: 'openai',
settings: {
model: 'tts-1',
voice: 'alloy',
},
secrets: {
apiKey: '[stored]',
},
})
const profileRow = db.prepare(
'SELECT settings_json, secrets_json FROM tts_profile_provider_settings WHERE profile = ? AND provider = ?'
).get('default', 'openai') as { settings_json: string; secrets_json: string }
expect(JSON.parse(profileRow.settings_json)).toMatchObject({ model: 'tts-1', voice: 'alloy' })
expect(JSON.parse(profileRow.secrets_json)).toEqual({ apiKey: 'server-secret' })
} finally {
db.close()
vi.doUnmock('../../packages/server/src/db/index')
}
})
it('deletes a stored TTS provider row and falls back to Edge when it was active', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
try {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx: saveCtx } = createMockCtx({
settings: { model: 'tts-1', voice: 'alloy' },
secrets: { apiKey: 'server-secret' },
})
saveCtx.state = { user: { id: 7 } }
saveCtx.params = { provider: 'openai' }
saveCtx.query = {}
saveCtx.get = vi.fn(() => '')
await ctrl.saveSettings(saveCtx)
const { ctx: deleteCtx } = createMockCtx()
deleteCtx.state = { user: { id: 7 } }
deleteCtx.params = { provider: 'openai' }
deleteCtx.query = {}
deleteCtx.get = vi.fn(() => '')
await ctrl.deleteProvider(deleteCtx)
expect(deleteCtx.status).toBe(200)
expect(deleteCtx.body).toEqual({
success: true,
deleted: true,
activeProvider: 'edge',
})
expect(db.prepare(
'SELECT COUNT(*) AS count FROM tts_profile_provider_settings WHERE profile = ? AND provider = ?'
).get('default', 'openai').count).toBe(0)
const activeRow = db.prepare('SELECT active_provider FROM tts_profile_settings WHERE profile = ?').get('default') as { active_provider: string }
expect(activeRow.active_provider).toBe('edge')
} finally {
db.close()
vi.doUnmock('../../packages/server/src/db/index')
}
})
it('rejects deleting the built-in Edge TTS provider', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
try {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx()
ctx.state = { user: { id: 7 } }
ctx.params = { provider: 'edge' }
ctx.query = {}
ctx.get = vi.fn(() => '')
await ctrl.deleteProvider(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'built-in TTS provider cannot be deleted' })
} finally {
db.close()
vi.doUnmock('../../packages/server/src/db/index')
}
})
it('preserves numeric Edge TTS rate and pitch settings on save', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
try {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
settings: {
voice: 'zh-CN-YunxiNeural',
rate: 1.35,
pitch: -6,
},
})
ctx.state = { user: { id: 7 } }
ctx.params = { provider: 'edge' }
ctx.query = {}
ctx.get = vi.fn(() => '')
await ctrl.saveSettings(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.setting).toMatchObject({
provider: 'edge',
settings: {
voice: 'zh-CN-YunxiNeural',
rate: '1.35',
pitch: '-6',
},
})
const listCtx = createMockCtx().ctx
listCtx.state = { user: { id: 7 } }
listCtx.query = {}
listCtx.get = vi.fn(() => '')
await ctrl.listSettings(listCtx)
expect(listCtx.body.settings).toEqual([
expect.objectContaining({
provider: 'edge',
settings: expect.objectContaining({
rate: '1.35',
pitch: '-6',
}),
}),
])
} finally {
db.close()
vi.doUnmock('../../packages/server/src/db/index')
}
})
it('repairs preexisting profile TTS tables before saving settings', async () => {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
try {
db.exec(`
CREATE TABLE tts_profile_provider_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile TEXT NOT NULL DEFAULT 'default',
provider TEXT NOT NULL,
settings_json TEXT NOT NULL DEFAULT '{}',
secrets_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
db.exec(`
CREATE TABLE tts_profile_settings (
profile TEXT NOT NULL DEFAULT 'default',
active_provider TEXT NOT NULL DEFAULT 'edge',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
db.prepare(
'INSERT INTO tts_profile_provider_settings (profile, provider, settings_json, secrets_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('default', 'edge', '{"voice":"old"}', '{}', 1, 1)
db.prepare(
'INSERT INTO tts_profile_provider_settings (profile, provider, settings_json, secrets_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('default', 'edge', '{"voice":"newer"}', '{}', 2, 2)
db.prepare(
'INSERT INTO tts_profile_settings (profile, active_provider, created_at, updated_at) VALUES (?, ?, ?, ?)'
).run('default', 'edge', 1, 1)
db.prepare(
'INSERT INTO tts_profile_settings (profile, active_provider, created_at, updated_at) VALUES (?, ?, ?, ?)'
).run('default', 'openai', 2, 2)
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
settings: { voice: 'zh-CN-XiaoxiaoNeural' },
})
ctx.state = { user: { id: 7 } }
ctx.params = { provider: 'edge' }
ctx.query = {}
ctx.get = vi.fn(() => '')
await ctrl.saveSettings(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.setting.settings.voice).toBe('zh-CN-XiaoxiaoNeural')
expect(db.prepare('SELECT COUNT(*) AS count FROM tts_profile_provider_settings WHERE profile = ? AND provider = ?').get('default', 'edge').count).toBe(1)
expect(db.prepare('SELECT COUNT(*) AS count FROM tts_profile_settings WHERE profile = ?').get('default').count).toBe(1)
} finally {
db.close()
vi.doUnmock('../../packages/server/src/db/index')
}
})
it('uses the active TTS provider when request provider is omitted', async () => {
const audio = Buffer.from('active-audio')
const provider = {
synthesize: vi.fn().mockResolvedValue({
audio,
contentType: 'audio/mpeg',
engine: 'doubao',
provider: 'doubao',
}),
}
const getTtsProvider = vi.fn(() => provider)
const getActiveTtsProvider = vi.fn(() => 'doubao')
const getTtsProviderSetting = vi.fn(() => null)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
vi.doMock('../../packages/server/src/db/hermes/tts-settings-store', async (importOriginal) => ({
...await importOriginal<typeof import('../../packages/server/src/db/hermes/tts-settings-store')>(),
getActiveTtsProvider,
getTtsProviderSetting,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({ text: 'hello' })
ctx.state = { user: { id: 7 } }
await ctrl.synthesize(ctx)
expect(getActiveTtsProvider).toHaveBeenCalledWith('default')
expect(getTtsProvider).toHaveBeenCalledWith('doubao')
expect(provider.synthesize).toHaveBeenCalledWith(
{ text: 'hello', signal: expect.any(AbortSignal) },
{},
)
})
it('falls back to Edge TTS when request and active providers are missing', async () => {
const audio = Buffer.from('edge-audio')
const provider = {
synthesize: vi.fn().mockResolvedValue({
audio,
contentType: 'audio/mpeg',
engine: 'edge',
provider: 'edge',
}),
}
const getTtsProvider = vi.fn(() => provider)
const getActiveTtsProvider = vi.fn(() => null)
const getTtsProviderSetting = vi.fn(() => null)
const listTtsProviderSettings = vi.fn(() => [])
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
vi.doMock('../../packages/server/src/db/hermes/tts-settings-store', async (importOriginal) => ({
...await importOriginal<typeof import('../../packages/server/src/db/hermes/tts-settings-store')>(),
getActiveTtsProvider,
getTtsProviderSetting,
listTtsProviderSettings,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({ text: 'hello' })
ctx.state = { user: { id: 7 } }
await ctrl.synthesize(ctx)
expect(getActiveTtsProvider).toHaveBeenCalledWith('default')
expect(listTtsProviderSettings).toHaveBeenCalledWith('default')
expect(getTtsProvider).toHaveBeenCalledWith('edge')
expect(provider.synthesize).toHaveBeenCalledWith(
{ text: 'hello', signal: expect.any(AbortSignal) },
{},
)
})
it('returns 400 for missing or blank text', async () => {
const provider = { synthesize: vi.fn() }
const getTtsProvider = vi.fn(() => provider)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({ provider: 'mimo', text: ' ' })
await ctrl.synthesize(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'text is required' })
expect(provider.synthesize).not.toHaveBeenCalled()
})
it('returns 400 when options is not an object', async () => {
const provider = { synthesize: vi.fn() }
const getTtsProvider = vi.fn(() => provider)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
for (const options of ['voice=verse', null, ['verse']]) {
const { ctx } = createMockCtx({ provider: 'mimo', text: 'hello', options })
await ctrl.synthesize(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'options must be an object' })
}
expect(provider.synthesize).not.toHaveBeenCalled()
})
it('calls the provider, returns audio headers, and writes the audio buffer body', async () => {
const audio = Buffer.from('mimo-audio')
const provider = {
synthesize: vi.fn().mockResolvedValue({
audio,
contentType: 'audio/wav',
engine: 'mimo',
provider: 'mimo',
}),
}
const getTtsProvider = vi.fn(() => provider)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx, headers } = createMockCtx({
provider: 'mimo',
text: 'Hello world',
options: { voice: 'verse' },
})
await ctrl.synthesize(ctx)
expect(provider.synthesize).toHaveBeenCalledTimes(1)
expect(provider.synthesize).toHaveBeenCalledWith(
{
text: 'Hello world',
signal: expect.any(AbortSignal),
},
{ voice: 'verse' },
)
expect(headers).toEqual({
'Content-Type': 'audio/wav',
'Content-Length': String(audio.length),
'X-TTS-Engine': 'mimo',
'X-TTS-Provider': 'mimo',
})
expect(ctx.body).toBe(audio)
})
it('keeps request API keys when no stored TTS setting exists', async () => {
const audio = Buffer.from('openai-audio')
const provider = {
synthesize: vi.fn().mockResolvedValue({
audio,
contentType: 'audio/mpeg',
engine: 'openai',
provider: 'openai',
}),
}
const getTtsProvider = vi.fn(() => provider)
const getTtsProviderSetting = vi.fn(() => null)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
vi.doMock('../../packages/server/src/db/hermes/tts-settings-store', async (importOriginal) => ({
...await importOriginal<typeof import('../../packages/server/src/db/hermes/tts-settings-store')>(),
getTtsProviderSetting,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
provider: 'openai',
text: 'Hello world',
options: {
baseUrl: 'https://api.openai.com/v1/audio/speech',
apiKey: 'request-secret',
model: 'tts-1',
voice: 'alloy',
},
})
ctx.state = { user: { id: 7 } }
await ctrl.synthesize(ctx)
expect(getTtsProviderSetting).toHaveBeenCalledWith('default', 'openai', { includeSecrets: true })
expect(provider.synthesize).toHaveBeenCalledWith(
{ text: 'Hello world', signal: expect.any(AbortSignal) },
{
baseUrl: 'https://api.openai.com/v1/audio/speech',
apiKey: 'request-secret',
model: 'tts-1',
voice: 'alloy',
},
)
})
it('uses request API keys only when the stored TTS setting has no secret', async () => {
const audio = Buffer.from('openai-audio')
const provider = {
synthesize: vi.fn().mockResolvedValue({
audio,
contentType: 'audio/mpeg',
engine: 'openai',
provider: 'openai',
}),
}
const getTtsProvider = vi.fn(() => provider)
const getTtsProviderSetting = vi.fn(() => ({
provider: 'openai',
label: 'OpenAI TTS',
settings: {
baseUrl: 'https://stored.example.test/v1/audio/speech',
model: 'stored-model',
},
secrets: {},
}))
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
vi.doMock('../../packages/server/src/db/hermes/tts-settings-store', async (importOriginal) => ({
...await importOriginal<typeof import('../../packages/server/src/db/hermes/tts-settings-store')>(),
getTtsProviderSetting,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
provider: 'openai',
text: 'Hello world',
options: {
apiKey: 'request-secret',
voice: 'alloy',
},
})
ctx.state = { user: { id: 7 } }
await ctrl.synthesize(ctx)
expect(provider.synthesize).toHaveBeenCalledWith(
{ text: 'Hello world', signal: expect.any(AbortSignal) },
{
baseUrl: 'https://stored.example.test/v1/audio/speech',
model: 'stored-model',
apiKey: 'request-secret',
voice: 'alloy',
},
)
})
it('does not let request API keys replace a stored TTS secret', async () => {
const audio = Buffer.from('openai-audio')
const provider = {
synthesize: vi.fn().mockResolvedValue({
audio,
contentType: 'audio/mpeg',
engine: 'openai',
provider: 'openai',
}),
}
const getTtsProvider = vi.fn(() => provider)
const getTtsProviderSetting = vi.fn(() => ({
provider: 'openai',
label: 'OpenAI TTS',
settings: {
baseUrl: 'https://stored.example.test/v1/audio/speech',
model: 'stored-model',
},
secrets: {
apiKey: 'stored-secret',
},
}))
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
vi.doMock('../../packages/server/src/db/hermes/tts-settings-store', async (importOriginal) => ({
...await importOriginal<typeof import('../../packages/server/src/db/hermes/tts-settings-store')>(),
getTtsProviderSetting,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
provider: 'openai',
text: 'Hello world',
options: {
apiKey: 'request-secret',
voice: 'alloy',
},
})
ctx.state = { user: { id: 7 } }
await ctrl.synthesize(ctx)
expect(provider.synthesize).toHaveBeenCalledWith(
{ text: 'Hello world', signal: expect.any(AbortSignal) },
{
baseUrl: 'https://stored.example.test/v1/audio/speech',
model: 'stored-model',
apiKey: 'stored-secret',
voice: 'alloy',
},
)
})
it('aborts the provider signal on client disconnect, but not on normal request close', async () => {
const audio = Buffer.from('late-audio')
let capturedSignal: AbortSignal | undefined
let resolveSynthesize: (() => void) | undefined
const provider = {
synthesize: vi.fn().mockImplementation(async ({ signal }: { signal?: AbortSignal }) => {
capturedSignal = signal
await new Promise<void>((resolve) => {
resolveSynthesize = resolve
})
return {
audio,
contentType: 'audio/mpeg',
engine: 'mimo',
provider: 'mimo',
}
}),
}
const getTtsProvider = vi.fn(() => provider)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx, emitRequestAborted, emitResponseClose } = createMockCtx({ provider: 'mimo', text: 'Hello world' })
const pending = ctrl.synthesize(ctx)
expect(ctx.req.on).toHaveBeenCalledWith('aborted', expect.any(Function))
expect(ctx.res.on).toHaveBeenCalledWith('close', expect.any(Function))
expect(capturedSignal?.aborted).toBe(false)
emitResponseClose({ writableEnded: true })
expect(capturedSignal?.aborted).toBe(false)
emitRequestAborted()
expect(capturedSignal?.aborted).toBe(true)
resolveSynthesize?.()
await pending
})
it('returns 499 when the provider aborts', async () => {
const abortError = Object.assign(new Error('client went away'), { name: 'AbortError' })
const provider = {
synthesize: vi.fn().mockRejectedValue(abortError),
}
const getTtsProvider = vi.fn(() => provider)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({ provider: 'mimo', text: 'Hello world' })
await ctrl.synthesize(ctx)
expect(ctx.status).toBe(499)
expect(ctx.body).toEqual({ error: 'TTS request aborted' })
expect(JSON.stringify(ctx.body)).not.toContain('client went away')
})
it('returns sanitized provider details when the provider fails', async () => {
const provider = {
synthesize: vi.fn().mockRejectedValue(new Error('MiMo TTS returned 401: apiKey=*** body')),
}
const getTtsProvider = vi.fn(() => provider)
vi.doMock('../../packages/server/src/services/hermes/tts-providers', () => ({
getTtsProvider,
}))
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({ provider: 'mimo', text: 'Hello world' })
await ctrl.synthesize(ctx)
expect(ctx.status).toBe(401)
expect(ctx.body).toEqual({
error: 'TTS synthesis failed',
detail: 'MiMo TTS returned 401: apiKey=[redacted] body',
})
expect(JSON.stringify(ctx.body)).not.toContain('***')
})
it('probes OpenAI-compatible model endpoints, ranks models, and redacts errors', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
data: [
{ id: 'gpt-4o-mini' },
{ id: 'whisper-large-v3' },
{ id: 'tts-1-hd' },
],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ error: { message: 'bad sk-test-secret-value key' } }), { status: 401, statusText: 'Unauthorized' }))
globalThis.fetch = fetchMock as any
try {
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
kind: 'tts',
provider: 'custom',
compatibility: 'openai-compatible',
baseUrl: 'https://api.example.com/openai/v1/audio/speech/',
apiKey: 'sk-test-secret-value',
})
ctx.state = { user: { id: 1 } }
await ctrl.probeProvider(ctx)
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/openai/v1/models', expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer sk-test-secret-value' }),
}))
expect(ctx.body.ok).toBe(true)
expect(ctx.body.models[0].id).toBe('tts-1-hd')
expect(ctx.body.recommendedModel).toBe('tts-1-hd')
expect(ctx.body.normalizedBaseUrl).toBe('https://api.example.com/openai/v1/audio/speech')
const failed = createMockCtx({
kind: 'stt',
provider: 'custom',
compatibility: 'openai-compatible',
baseUrl: 'https://api.example.com/openai/v1',
apiKey: 'sk-test-secret-value',
})
failed.ctx.state = { user: { id: 1 } }
await ctrl.probeProvider(failed.ctx)
expect(failed.ctx.status).toBe(200)
expect(failed.ctx.body.ok).toBe(false)
expect(failed.ctx.body.errorSummary).toContain('Authentication failed')
expect(JSON.stringify(failed.ctx.body)).not.toContain('sk-test-secret-value')
expect(failed.ctx.body.manualModelAllowed).toBe(true)
} finally {
globalThis.fetch = originalFetch
}
})
it('does not call upstream discovery for manual custom endpoint probes', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn()
globalThis.fetch = fetchMock as any
try {
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
kind: 'stt',
provider: 'custom',
compatibility: 'manual',
baseUrl: 'https://manual.example.test/v1/',
apiKey: 'sk-test-secret-value',
})
ctx.state = { user: { id: 1 } }
await ctrl.probeProvider(ctx)
expect(fetchMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({
ok: true,
models: [],
recommendedModel: '',
errorSummary: '',
manualModelAllowed: true,
normalizedBaseUrl: 'https://manual.example.test/v1',
})
} finally {
globalThis.fetch = originalFetch
}
})
it('allows local or private network provider probe targets', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
data: [{ id: 'tts-local' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
globalThis.fetch = fetchMock as any
try {
const ctrl = await import('../../packages/server/src/controllers/hermes/tts')
const { ctx } = createMockCtx({
kind: 'tts',
provider: 'custom',
compatibility: 'openai-compatible',
baseUrl: 'http://127.0.0.1:8080/v1',
apiKey: 'sk-tes...alue',
})
ctx.state = { user: { id: 1 } }
await ctrl.probeProvider(ctx)
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8080/v1/models', expect.any(Object))
expect(ctx.status).toBe(200)
expect(ctx.body.ok).toBe(true)
expect(ctx.body.models).toEqual([expect.objectContaining({ id: 'tts-local' })])
expect(JSON.stringify(ctx.body)).not.toContain('sk-tes...alue')
} finally {
globalThis.fetch = originalFetch
}
})
})
describe('tts routes', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.doUnmock('../../packages/server/src/routes/hermes/tts')
vi.doUnmock('../../packages/server/src/controllers/hermes/tts')
})
it('registers public legacy routes separately from the protected synthesize route', async () => {
const generate = vi.fn(async (ctx: any) => { ctx.body = { route: 'generate' } })
const openaiProxy = vi.fn(async (ctx: any) => { ctx.body = { route: 'openaiProxy' } })
const synthesize = vi.fn(async (ctx: any) => { ctx.body = { route: 'synthesize' } })
const listSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'listSettings' } })
const saveSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveSettings' } })
const saveActiveProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveActiveProvider' } })
const deleteProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteProvider' } })
const deleteBaseUrlPreset = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteBaseUrlPreset' } })
const deleteSecret = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteSecret' } })
const probeProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'probeProvider' } })
const mcuAudio = vi.fn(async (ctx: any) => { ctx.body = { route: 'mcuAudio' } })
vi.doMock('../../packages/server/src/controllers/hermes/tts', () => ({
generate,
openaiProxy,
mcuAudio,
synthesize,
listSettings,
saveSettings,
saveActiveProvider,
deleteProvider,
deleteBaseUrlPreset,
deleteSecret,
probeProvider,
}))
const { ttsRoutes, ttsProtectedRoutes } = await import('../../packages/server/src/routes/hermes/tts')
const paths = ttsRoutes.stack.map((entry: any) => entry.path)
const protectedPaths = ttsProtectedRoutes.stack.map((entry: any) => entry.path)
expect(paths).toEqual(expect.arrayContaining([
'/api/hermes/tts',
'/api/tts/proxy/audio/speech',
'/api/hermes/mcu/audio/:file',
]))
expect(paths).not.toContain('/api/hermes/tts/synthesize')
expect(protectedPaths).toEqual(expect.arrayContaining([
'/api/hermes/tts/settings',
'/api/hermes/tts/settings/active',
'/api/hermes/tts/settings/:provider',
'/api/hermes/tts/settings/:provider',
'/api/hermes/tts/settings/:provider/base-url-preset',
'/api/hermes/tts/settings/:provider/secret/:secretName',
'/api/voice/providers/probe',
'/api/hermes/tts/synthesize',
]))
const synthLayer: any = ttsProtectedRoutes.stack.find((entry: any) => entry.path === '/api/hermes/tts/synthesize')
const ctx: any = { request: { body: {} }, body: null }
await synthLayer.stack[0](ctx, undefined)
expect(synthesize).toHaveBeenCalledWith(ctx, undefined)
expect(ctx.body).toEqual({ route: 'synthesize' })
})
})
describe('route registration ordering', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.doUnmock('../../packages/server/src/routes/index')
})
it('mounts protected synthesize routes after requireAuth', async () => {
const ttsPublicMiddleware = async () => {}
const ttsProtectedMiddleware = async () => {}
vi.doMock('../../packages/server/src/routes/hermes/tts', () => ({
ttsRoutes: { routes: vi.fn(() => ttsPublicMiddleware) },
ttsProtectedRoutes: { routes: vi.fn(() => ttsProtectedMiddleware) },
}))
const { registerRoutes } = await import('../../packages/server/src/routes/index')
const use = vi.fn()
const app = { use }
const requireAuth = vi.fn(async () => {})
registerRoutes(app as any, [requireAuth] as any)
const mountedMiddleware = use.mock.calls.map(([middleware]) => middleware)
expect(mountedMiddleware.indexOf(ttsPublicMiddleware)).toBeGreaterThanOrEqual(0)
expect(mountedMiddleware.indexOf(requireAuth)).toBeGreaterThan(mountedMiddleware.indexOf(ttsPublicMiddleware))
expect(mountedMiddleware.indexOf(ttsProtectedMiddleware)).toBeGreaterThan(mountedMiddleware.indexOf(requireAuth))
})
})