Files
hermes-ui/tests/server/stt-settings-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

338 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('stt settings controller', () => {
let db: any = null
beforeEach(async () => {
vi.resetModules()
const { DatabaseSync } = await import('node:sqlite')
db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
})
afterEach(() => {
db?.close()
db = null
vi.doUnmock('../../packages/server/src/db/index')
vi.resetModules()
})
async function initController() {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
return await import('../../packages/server/src/controllers/hermes/stt')
}
function makeCtx(user: any | null, body: any = {}, params: Record<string, string> = {}, query: Record<string, string> = {}) {
return {
state: user ? { user } : {},
request: { body },
params,
query,
status: 200,
body: null,
set: vi.fn(),
get: vi.fn(() => ''),
} as any
}
it('saves masked settings rows and lists them for the authenticated user without leaking secrets', async () => {
const ctrl = await initController()
const user = { id: 9, username: 'alice', role: 'admin' }
const saveCtx = makeCtx(user, {
settings: {
model: 'gpt-4o-transcribe',
language: 'en',
},
secrets: {
apiKey: 'server-secret',
},
}, { provider: 'openai' })
await ctrl.saveSettings(saveCtx)
expect(saveCtx.status).toBe(200)
expect(saveCtx.body.setting).toMatchObject({
provider: 'openai',
settings: {
model: 'gpt-4o-transcribe',
language: 'en',
},
secrets: {
apiKey: '[stored]',
},
})
expect(JSON.stringify(saveCtx.body)).not.toContain('server-secret')
const profileProviderRow = db.prepare(
'SELECT provider, settings_json, secrets_json FROM stt_profile_provider_settings WHERE profile = ? AND provider = ?'
).get('default', 'openai') as { provider: string; settings_json: string; secrets_json: string }
expect(profileProviderRow.provider).toBe('openai')
expect(JSON.parse(profileProviderRow.settings_json)).toMatchObject({
model: 'gpt-4o-transcribe',
language: 'en',
})
expect(JSON.parse(profileProviderRow.secrets_json)).toEqual({ apiKey: 'server-secret' })
const profileActiveRow = db.prepare(
'SELECT active_provider FROM stt_profile_settings WHERE profile = ?'
).get('default') as { active_provider: string }
expect(profileActiveRow.active_provider).toBe('openai')
const listCtx = makeCtx(user)
await ctrl.listSettings(listCtx)
expect(listCtx.status).toBe(200)
expect(listCtx.body).toEqual({
settings: [saveCtx.body.setting],
activeProvider: 'openai',
})
expect(JSON.stringify(listCtx.body)).not.toContain('server-secret')
})
it('deletes stored secrets while keeping the settings row masked', async () => {
const ctrl = await initController()
const user = { id: 7, username: 'bob', role: 'admin' }
const saveCtx = makeCtx(user, {
settings: {
model: 'gpt-4o-transcribe',
},
secrets: {
apiKey: 'server-secret',
},
}, { provider: 'openai' })
await ctrl.saveSettings(saveCtx)
const deleteCtx = makeCtx(user, {}, { provider: 'openai', secretName: 'apiKey' })
await ctrl.deleteSecret(deleteCtx)
expect(deleteCtx.status).toBe(200)
expect(deleteCtx.body).toEqual({
success: true,
setting: expect.objectContaining({
provider: 'openai',
settings: {
model: 'gpt-4o-transcribe',
},
secrets: {},
}),
})
expect(JSON.stringify(deleteCtx.body)).not.toContain('server-secret')
const listCtx = makeCtx(user)
await ctrl.listSettings(listCtx)
expect(listCtx.body).toEqual({
settings: [deleteCtx.body.setting],
activeProvider: 'openai',
})
})
it('deletes a stored STT provider row and falls back to browser when it was active', async () => {
const ctrl = await initController()
const user = { id: 7, username: 'bob', role: 'admin' }
await ctrl.saveSettings(makeCtx(user, {
settings: { model: 'gpt-4o-transcribe' },
secrets: { apiKey: 'server-secret' },
}, { provider: 'openai' }))
const deleteCtx = makeCtx(user, {}, { provider: 'openai' })
await ctrl.deleteProvider(deleteCtx)
expect(deleteCtx.status).toBe(200)
expect(deleteCtx.body).toEqual({
success: true,
deleted: true,
activeProvider: 'browser',
})
expect(db.prepare(
'SELECT COUNT(*) AS count FROM stt_profile_provider_settings WHERE profile = ? AND provider = ?'
).get('default', 'openai').count).toBe(0)
const activeRow = db.prepare('SELECT active_provider FROM stt_profile_settings WHERE profile = ?').get('default') as { active_provider: string }
expect(activeRow.active_provider).toBe('browser')
})
it('deletes saved custom base URL presets without deleting the current setting or secret', async () => {
const ctrl = await initController()
const user = { id: 13, username: 'dana', role: 'admin' }
const saveFirstCtx = makeCtx(user, {
settings: {
baseUrl: 'https://api.groq.com/openai/v1',
model: 'whisper-large-v3-turbo',
},
secrets: { apiKey: 'server-secret' },
}, { provider: 'custom' })
await ctrl.saveSettings(saveFirstCtx)
const saveSecondCtx = makeCtx(user, {
settings: {
baseUrl: 'https://stt.example.test/openai/v1',
model: 'whisper-large-v3-turbo',
},
}, { provider: 'custom' })
await ctrl.saveSettings(saveSecondCtx)
expect(saveSecondCtx.body.setting.settings.baseUrlPresets).toEqual([
'https://stt.example.test/openai/v1',
'https://api.groq.com/openai/v1',
])
const deleteCtx = makeCtx(
user,
{},
{ provider: 'custom' },
{ url: 'https://api.groq.com/openai/v1' },
)
await ctrl.deleteBaseUrlPreset(deleteCtx)
expect(deleteCtx.status).toBe(200)
expect(deleteCtx.body.setting).toMatchObject({
provider: 'custom',
settings: {
baseUrl: 'https://stt.example.test/openai/v1',
baseUrlPresets: ['https://stt.example.test/openai/v1'],
model: 'whisper-large-v3-turbo',
},
secrets: { apiKey: '[stored]' },
})
expect(JSON.stringify(deleteCtx.body)).not.toContain('server-secret')
const deleteCurrentCtx = makeCtx(
user,
{},
{ provider: 'custom' },
{ url: 'https://stt.example.test/openai/v1' },
)
await ctrl.deleteBaseUrlPreset(deleteCurrentCtx)
expect(deleteCurrentCtx.status).toBe(200)
expect(deleteCurrentCtx.body.setting).toMatchObject({
provider: 'custom',
settings: {
model: 'whisper-large-v3-turbo',
},
secrets: { apiKey: '[stored]' },
})
expect(deleteCurrentCtx.body.setting.settings.baseUrl).toBeUndefined()
expect(deleteCurrentCtx.body.setting.settings.baseUrlPresets).toBeUndefined()
})
it('rejects unauthenticated requests and invalid provider inputs', async () => {
const ctrl = await initController()
const listCtx = makeCtx(null)
await ctrl.listSettings(listCtx)
expect(listCtx.status).toBe(401)
expect(listCtx.body).toEqual({ error: 'Unauthorized' })
const saveCtx = makeCtx(null, {}, { provider: 'openai' })
await ctrl.saveSettings(saveCtx)
expect(saveCtx.status).toBe(401)
expect(saveCtx.body).toEqual({ error: 'Unauthorized' })
const deletePresetCtx = makeCtx(null, {}, { provider: 'custom' }, { url: 'https://api.groq.com/openai/v1' })
await ctrl.deleteBaseUrlPreset(deletePresetCtx)
expect(deletePresetCtx.status).toBe(401)
expect(deletePresetCtx.body).toEqual({ error: 'Unauthorized' })
const deleteCtx = makeCtx(null, {}, { provider: 'openai', secretName: 'apiKey' })
await ctrl.deleteSecret(deleteCtx)
expect(deleteCtx.status).toBe(401)
expect(deleteCtx.body).toEqual({ error: 'Unauthorized' })
const authedUser = { id: 4, username: 'eve', role: 'admin' }
const badProviderCtx = makeCtx(authedUser, {}, { provider: 'nope' })
await ctrl.saveSettings(badProviderCtx)
expect(badProviderCtx.status).toBe(400)
expect(badProviderCtx.body).toEqual({ error: 'unknown STT provider' })
const badSecretCtx = makeCtx(authedUser, {}, { provider: 'openai', secretName: 'token' })
await ctrl.deleteSecret(badSecretCtx)
expect(badSecretCtx.status).toBe(400)
expect(badSecretCtx.body).toEqual({ error: 'unknown STT provider secret' })
const badActiveCtx = makeCtx(authedUser, { provider: 'nope' })
await ctrl.saveActiveProvider(badActiveCtx)
expect(badActiveCtx.status).toBe(400)
expect(badActiveCtx.body).toEqual({ error: 'unknown STT provider' })
})
it('saves browser as the active STT provider without creating a provider-secret row', async () => {
const ctrl = await initController()
const user = { id: 11, username: 'carol', role: 'admin' }
const ctx = makeCtx(user, { provider: 'browser' })
await ctrl.saveActiveProvider(ctx)
expect(ctx.body).toEqual({ activeProvider: 'browser' })
const listCtx = makeCtx(user)
await ctrl.listSettings(listCtx)
expect(listCtx.body).toEqual({ settings: [], activeProvider: 'browser' })
})
})
describe('stt routes', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.doUnmock('../../packages/server/src/routes/hermes/stt')
vi.doUnmock('../../packages/server/src/controllers/hermes/stt')
})
it('registers the protected STT settings and transcribe routes', async () => {
const listSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'listSettings' } })
const saveActiveProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveActiveProvider' } })
const saveSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveSettings' } })
const deleteProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteProvider' } })
const deleteSecret = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteSecret' } })
const deleteBaseUrlPreset = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteBaseUrlPreset' } })
const profileStatus = vi.fn(async (ctx: any) => { ctx.body = { route: 'profileStatus' } })
const missingProfileAudio = vi.fn(async (ctx: any) => { ctx.body = { route: 'missingProfileAudio' } })
const mcuVoiceTurn = vi.fn(async (ctx: any) => { ctx.body = { route: 'mcuVoiceTurn' } })
const transcribe = vi.fn(async (ctx: any) => { ctx.body = { route: 'transcribe' } })
vi.doMock('../../packages/server/src/controllers/hermes/stt', () => ({
listSettings,
saveActiveProvider,
saveSettings,
deleteProvider,
deleteSecret,
deleteBaseUrlPreset,
profileStatus,
missingProfileAudio,
mcuVoiceTurn,
transcribe,
}))
const { sttProtectedRoutes } = await import('../../packages/server/src/routes/hermes/stt')
const protectedPaths = sttProtectedRoutes.stack.map((entry: any) => entry.path)
expect(protectedPaths).toEqual(expect.arrayContaining([
'/api/hermes/stt/settings',
'/api/hermes/stt/profile-status',
'/api/hermes/stt/profile-status/missing-audio',
'/api/hermes/mcu/voice-turn',
'/api/hermes/stt/settings/active',
'/api/hermes/stt/settings/:provider',
'/api/hermes/stt/settings/:provider',
'/api/hermes/stt/settings/:provider/base-url-preset',
'/api/hermes/stt/settings/:provider/secret/:secretName',
'/api/hermes/stt/transcribe',
]))
const transcribeLayer: any = sttProtectedRoutes.stack.find((entry: any) => entry.path === '/api/hermes/stt/transcribe')
const ctx: any = { request: { body: {} }, body: null }
await transcribeLayer.stack[0](ctx, undefined)
expect(transcribe).toHaveBeenCalledWith(ctx, undefined)
expect(ctx.body).toEqual({ route: 'transcribe' })
})
})