961 lines
33 KiB
TypeScript
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))
|
|
})
|
|
})
|