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

678 lines
23 KiB
TypeScript

import { Readable } from 'stream'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
function jsonResponse(body: unknown, init: { status?: number; statusText?: string } = {}) {
return {
ok: (init.status ?? 200) >= 200 && (init.status ?? 200) < 300,
status: init.status ?? 200,
statusText: init.statusText ?? 'OK',
async json() {
return body
},
async text() {
return JSON.stringify(body)
},
}
}
function textResponse(body: string, init: { status?: number; statusText?: string } = {}) {
return {
ok: (init.status ?? 500) >= 200 && (init.status ?? 500) < 300,
status: init.status ?? 500,
statusText: init.statusText ?? 'Error',
async json() {
return { error: body }
},
async text() {
return body
},
}
}
function multipartBody(
boundary: string,
parts: Array<{
name: string
value: string | Buffer
filename?: string
filenameStar?: string
contentType?: string
}>,
): Buffer {
const chunks: Buffer[] = []
for (const part of parts) {
chunks.push(Buffer.from(`--${boundary}\r\n`))
const filename = part.filename ? `; filename="${part.filename}"` : ''
const filenameStar = part.filenameStar ? `; filename*=UTF-8''${part.filenameStar}` : ''
chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.name}"${filename}${filenameStar}\r\n`))
if (part.contentType) {
chunks.push(Buffer.from(`Content-Type: ${part.contentType}\r\n`))
}
chunks.push(Buffer.from('\r\n'))
chunks.push(Buffer.isBuffer(part.value) ? part.value : Buffer.from(part.value))
chunks.push(Buffer.from('\r\n'))
}
chunks.push(Buffer.from(`--${boundary}--\r\n`))
return Buffer.concat(chunks)
}
function getHeader(headers: RequestInit['headers'] | undefined, name: string): string | undefined {
if (!headers) return undefined
if (headers instanceof Headers) return headers.get(name) ?? undefined
if (Array.isArray(headers)) {
const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase())
return match?.[1]
}
const match = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())
return typeof match?.[1] === 'string' ? match[1] : undefined
}
async function waitForMockCalls(mock: { mock: { calls: unknown[] } }, count: number): Promise<void> {
const startedAt = Date.now()
while (mock.mock.calls.length < count && Date.now() - startedAt < 1000) {
await new Promise(resolve => setTimeout(resolve, 5))
}
}
describe('stt transcribe controller', () => {
let db: any = null
let tempDir: string | null = null
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
const { DatabaseSync } = await import('node:sqlite')
db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
})
afterEach(async () => {
db?.close()
db = null
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
tempDir = null
}
vi.doUnmock('../../packages/server/src/db/index')
vi.doUnmock('../../packages/server/src/config')
vi.doUnmock('../../packages/server/src/services/hermes/stt-providers/audio-convert')
vi.doUnmock('../../packages/server/src/services/hermes/stt-providers')
vi.doUnmock('../../packages/server/src/services/global-agent/server')
vi.resetModules()
})
async function initControllerAndStore() {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
return {
ctrl: await import('../../packages/server/src/controllers/hermes/stt'),
store: await import('../../packages/server/src/db/hermes/stt-settings-store'),
}
}
function makeMultipartCtx(
user: any | null,
parts: Array<{ name: string; value: string | Buffer; filename?: string; filenameStar?: string; contentType?: string }>,
) {
const boundary = 'stt-boundary'
return {
state: user ? { user } : {},
request: {},
req: Readable.from([multipartBody(boundary, parts)]),
params: {},
status: 200,
body: null,
set: vi.fn(),
get: vi.fn((header: string) => header.toLowerCase() === 'content-type' ? `multipart/form-data; boundary=${boundary}` : ''),
} as any
}
function makeJsonCtx(user: any | null, provider: string, body: unknown) {
const headers: Record<string, string> = {}
return {
state: user ? { user } : {},
request: { body },
req: Readable.from([]),
query: {},
params: { provider },
status: 200,
body: null,
set: vi.fn((name: string, value: string) => { headers[name] = value }),
get: vi.fn(() => ''),
headers,
} as any
}
function makeRawAudioCtx(user: any | null, audio: Buffer, headers: Record<string, string> = {}) {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
)
return {
state: user ? { user } : {},
request: {},
req: Readable.from([audio]),
query: {},
params: {},
status: 200,
body: null,
set: vi.fn(),
get: vi.fn((header: string) => normalizedHeaders[header.toLowerCase()] || ''),
} as any
}
it('transcribes multipart audio using the stored secret and ignores client-supplied api keys', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'transcribed text' }))
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'openai', {
settings: {
model: 'gpt-4o-transcribe',
language: 'en',
},
secrets: {
apiKey: 'server-secret',
},
})
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[
{ name: 'provider', value: 'openai' },
{ name: 'apiKey', value: 'attacker-secret' },
{ name: 'secrets', value: '{"apiKey":"body-secret"}' },
{ name: 'audio', value: Buffer.from('audio-data'), filename: 'speech.webm', contentType: 'audio/webm' },
],
)
await ctrl.transcribe(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toMatchObject({
text: 'transcribed text',
provider: 'openai',
model: 'gpt-4o-transcribe',
language: 'en',
})
expect(ctx.body.durationMs).toEqual(expect.any(Number))
expect(JSON.stringify(ctx.body)).not.toContain('server-secret')
expect(JSON.stringify(ctx.body)).not.toContain('attacker-secret')
expect(JSON.stringify(ctx.body)).not.toContain('body-secret')
expect(mockFetch).toHaveBeenCalledTimes(1)
const [, init] = mockFetch.mock.calls[0] as [string | URL, RequestInit]
expect(getHeader(init.headers, 'Authorization')).toBe('Bearer server-secret')
expect(getHeader(init.headers, 'Authorization')).not.toContain('attacker-secret')
expect(init.body).toBeInstanceOf(FormData)
const form = init.body as FormData
expect(form.get('model')).toBe('gpt-4o-transcribe')
expect(form.get('language')).toBe('en')
})
it('masks stored api keys when listing STT settings', async () => {
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'openai', {
settings: {
model: 'gpt-4o-transcribe',
},
secrets: {
apiKey: 'server-secret',
},
})
const ctx = makeJsonCtx({ id: 7, username: 'han', role: 'admin' }, 'openai', undefined)
await ctrl.listSettings(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({
settings: [
expect.objectContaining({
provider: 'openai',
secrets: { apiKey: '[stored]' },
}),
],
activeProvider: null,
})
expect(JSON.stringify(ctx.body)).not.toContain('server-secret')
})
it('reports profile STT as unconfigured when no active provider is saved', async () => {
const { ctrl } = await initControllerAndStore()
const ctx = makeJsonCtx({ id: 7, username: 'han', role: 'admin' }, '', undefined)
ctx.query = { profile: 'default' }
await ctrl.profileStatus(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({
profile: 'default',
configured: false,
activeProvider: null,
reason: 'active_stt_provider_missing',
})
})
it('redirects to the bundled missing-STT prompt audio when profile STT is unconfigured', async () => {
const { ctrl } = await initControllerAndStore()
const ctx = makeJsonCtx({ id: 7, username: 'han', role: 'admin' }, '', undefined)
await ctrl.missingProfileAudio(ctx)
expect(ctx.status).toBe(302)
expect(ctx.headers.Location).toBe('/api/hermes/mcu/audio/missing-stt-24k.s16le.pcm')
expect(ctx.headers['X-Hermes-STT-Configured']).toBe('false')
expect(ctx.body).toEqual({
url: '/api/hermes/mcu/audio/missing-stt-24k.s16le.pcm',
})
})
it('returns 204 from missing-STT prompt audio when profile STT is configured', async () => {
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'doubao', {
settings: { model: 'volc.seedasr.auc' },
secrets: { apiKey: 'server-secret' },
})
store.saveActiveSttProvider('default', 'doubao')
const ctx = makeJsonCtx({ id: 7, username: 'han', role: 'admin' }, '', undefined)
await ctrl.missingProfileAudio(ctx)
expect(ctx.status).toBe(204)
expect(ctx.body).toBeNull()
})
it('returns 400 when multipart audio is missing', async () => {
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'openai', {
settings: { model: 'gpt-4o-transcribe' },
secrets: { apiKey: 'server-secret' },
})
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[{ name: 'provider', value: 'openai' }],
)
await ctrl.transcribe(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'audio is required' })
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns 400 for malformed multipart filename* without throwing', async () => {
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'openai', {
settings: { model: 'gpt-4o-transcribe' },
secrets: { apiKey: 'server-secret' },
})
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[
{ name: 'provider', value: 'openai' },
{
name: 'audio',
value: Buffer.from('audio-data'),
filenameStar: 'bad%ZZname.webm',
contentType: 'audio/webm',
},
],
)
await expect(ctrl.transcribe(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Malformed multipart filename' })
expect(JSON.stringify(ctx.body)).not.toContain('URIError')
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns 400 when no saved STT settings exist for the requested provider', async () => {
const { ctrl } = await initControllerAndStore()
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[
{ name: 'provider', value: 'openai' },
{ name: 'audio', value: Buffer.from('audio-data'), filename: 'speech.webm', contentType: 'audio/webm' },
],
)
await ctrl.transcribe(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'STT settings are required for provider openai' })
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns 400 for saved custom provider settings missing baseUrl', async () => {
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'custom', {
settings: {
model: 'whisper-1',
},
secrets: {
apiKey: 'server-secret',
},
})
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[
{ name: 'provider', value: 'custom' },
{ name: 'audio', value: Buffer.from('audio-data'), filename: 'speech.webm', contentType: 'audio/webm' },
],
)
await expect(ctrl.transcribe(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Custom STT baseUrl is required' })
expect(JSON.stringify(ctx.body)).not.toContain('server-secret')
expect(mockFetch).not.toHaveBeenCalled()
})
it('ignores client-supplied custom baseUrl, apiKey, and headers during transcription', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'custom transcript' }))
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'custom', {
settings: {
baseUrl: 'https://example.com/v1',
model: 'whisper-1',
language: 'fr',
},
secrets: {
apiKey: 'server-secret',
},
})
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[
{ name: 'provider', value: 'custom' },
{ name: 'baseUrl', value: 'http://127.0.0.1:8000/v1/audio/transcriptions' },
{ name: 'apiKey', value: 'attacker-secret' },
{ name: 'headers', value: '{"Authorization":"Bearer attacker-secret"}' },
{ name: 'settings', value: '{"baseUrl":"http://169.254.169.254/latest/meta-data"}' },
{ name: 'audio', value: Buffer.from('audio-data'), filename: 'speech.webm', contentType: 'audio/webm' },
],
)
await ctrl.transcribe(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toMatchObject({
text: 'custom transcript',
provider: 'custom',
model: 'whisper-1',
language: 'fr',
})
expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, init] = mockFetch.mock.calls[0] as [string | URL, RequestInit]
expect(String(url)).toBe('https://example.com/v1/audio/transcriptions')
expect(getHeader(init.headers, 'Authorization')).toBe('Bearer server-secret')
expect(getHeader(init.headers, 'Authorization')).not.toContain('attacker-secret')
})
it('transcribes multipart audio with saved Doubao STT settings', async () => {
mockFetch
.mockResolvedValueOnce({
...jsonResponse({}),
headers: new Headers({ 'X-Api-Status-Code': '20000000' }),
})
.mockResolvedValueOnce({
...jsonResponse({ result: { text: '豆包识别文本' } }),
headers: new Headers({ 'X-Api-Status-Code': '20000000' }),
})
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'doubao', {
settings: {
baseUrl: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel',
model: 'volc.seedasr.auc',
},
secrets: {
apiKey: 'server-secret',
},
})
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[
{ name: 'provider', value: 'doubao' },
{ name: 'apiKey', value: 'attacker-secret' },
{ name: 'audio', value: Buffer.from('wav-audio'), filename: 'speech.wav', contentType: 'audio/wav' },
],
)
await ctrl.transcribe(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toMatchObject({
text: '豆包识别文本',
provider: 'doubao',
model: 'volc.seedasr.auc',
})
expect(JSON.stringify(ctx.body)).not.toContain('server-secret')
expect(JSON.stringify(ctx.body)).not.toContain('attacker-secret')
expect(mockFetch).toHaveBeenCalledTimes(2)
const [url, init] = mockFetch.mock.calls[0] as [string | URL, RequestInit]
expect(String(url)).toBe('https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit')
expect(getHeader(init.headers, 'X-Api-Key')).toBe('server-secret')
expect(getHeader(init.headers, 'X-Api-Key')).not.toContain('attacker-secret')
})
it('passes MCU WAV audio to Doubao without ffmpeg normalization', async () => {
const audioConvertMock = {
transcodeToWav: vi.fn(async (audio: Buffer) => ({
audio: Buffer.concat([Buffer.from('converted:'), audio]),
mimeType: 'audio/wav',
fileName: 'audio.wav',
})),
}
tempDir = mkdtempSync(join(tmpdir(), 'hermes-mcu-stt-test-'))
vi.doMock('../../packages/server/src/config', () => ({
config: { appHome: tempDir },
}))
vi.doMock('../../packages/server/src/services/hermes/stt-providers/audio-convert', () => audioConvertMock)
mockFetch
.mockResolvedValueOnce({
...jsonResponse({}),
headers: new Headers({ 'X-Api-Status-Code': '20000000' }),
})
.mockResolvedValueOnce({
...jsonResponse({ result: { text: '你好' } }),
headers: new Headers({ 'X-Api-Status-Code': '20000000' }),
})
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'doubao', {
settings: {
baseUrl: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel',
model: 'volc.seedasr.auc',
},
secrets: {
apiKey: 'server-secret',
},
})
store.saveActiveSttProvider('default', 'doubao')
const wav = Buffer.from('raw-mcu-wav')
const ctx = makeRawAudioCtx(
{ id: 7, username: 'han', role: 'admin' },
wav,
{
'content-type': 'audio/wav',
authorization: 'Bearer user-token',
'x-hermes-mcu-interaction-id': 'voice-1',
},
)
await ctrl.mcuVoiceTurn(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toMatchObject({ ok: true, accepted: true, interactionId: 'voice-1' })
await waitForMockCalls(mockFetch, 2)
expect(audioConvertMock.transcodeToWav).not.toHaveBeenCalled()
expect(mockFetch).toHaveBeenCalledTimes(2)
const [, init] = mockFetch.mock.calls[0] as [string | URL, RequestInit]
expect(JSON.parse(String(init.body)).audio).toEqual({
format: 'wav',
data: wav.toString('base64'),
})
})
it('queues a separate prompt audio when MCU STT transcription fails after upload', async () => {
const emitMcuEvent = vi.fn()
vi.doMock('../../packages/server/src/services/global-agent/server', () => ({
getActiveGlobalAgentServer: () => ({ emitMcuEvent }),
}))
vi.doMock('../../packages/server/src/services/hermes/stt-providers', async (importOriginal) => ({
...await importOriginal<typeof import('../../packages/server/src/services/hermes/stt-providers')>(),
transcribeWithProvider: vi.fn(async () => {
throw new Error('provider unavailable')
}),
}))
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'openai', {
settings: {
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'gpt-4o-transcribe',
},
secrets: { apiKey: 'server-secret' },
})
store.saveActiveSttProvider('default', 'openai')
const ctx = makeRawAudioCtx(
{ id: 7, username: 'han', role: 'admin' },
Buffer.from('raw-mcu-wav'),
{
'content-type': 'audio/wav',
authorization: 'Bearer user-token',
'x-hermes-mcu-interaction-id': 'voice-1',
'x-hermes-mcu-device-id': 'device-1',
},
)
await ctrl.mcuVoiceTurn(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toMatchObject({ ok: true, accepted: true, interactionId: 'voice-1' })
await waitForMockCalls(emitMcuEvent, 3)
expect(emitMcuEvent).toHaveBeenCalledWith({
type: 'audio.enqueue',
interactionId: 'voice-1',
segmentId: 'voice-1-stt-failed',
text: '当前语音转文字失败了,请配置下语音转文字再使用哦',
url: '/api/hermes/mcu/audio/stt-failed-24k.s16le.pcm',
mimeType: 'audio/x-pcm',
format: 's16le',
sampleRate: 24000,
channels: 1,
}, { clientId: 'device-1' })
})
it.each([
'http://10.0.0.1:8000/v1/audio/transcriptions',
'http://172.16.0.1:8000/v1/audio/transcriptions',
'http://192.168.1.1:8000/v1/audio/transcriptions',
'http://127.0.0.1:8000/v1/audio/transcriptions',
'http://169.254.169.254/latest/meta-data',
'http://[::1]:8000/v1/audio/transcriptions',
'http://[fd00::1]:8000/v1/audio/transcriptions',
'http://[fe90::1]:8000/v1/audio/transcriptions',
])('allows local or private custom baseUrl %s when saving settings', async (baseUrl) => {
const { ctrl } = await initControllerAndStore()
const ctx = makeJsonCtx(
{ id: 7, username: 'han', role: 'admin' },
'custom',
{
settings: {
baseUrl,
model: 'whisper-1',
},
secrets: {
apiKey: 'server-secret',
},
},
)
await ctrl.saveSettings(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.setting.settings.baseUrl).toContain(new URL(baseUrl).origin)
expect(JSON.stringify(ctx.body)).not.toContain('server-secret')
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns 502 without leaking secrets when the provider fails', async () => {
mockFetch.mockResolvedValueOnce(textResponse('upstream rejected bearer server-secret', { status: 401, statusText: 'Unauthorized' }))
const { ctrl, store } = await initControllerAndStore()
store.saveSttProviderSetting('default', 'openai', {
settings: { model: 'gpt-4o-transcribe' },
secrets: { apiKey: 'server-secret' },
})
const ctx = makeMultipartCtx(
{ id: 7, username: 'han', role: 'admin' },
[
{ name: 'provider', value: 'openai' },
{ name: 'audio', value: Buffer.from('audio-data'), filename: 'speech.webm', contentType: 'audio/webm' },
],
)
await ctrl.transcribe(ctx)
expect(ctx.status).toBe(502)
expect(ctx.body).toEqual({
error: 'STT transcription failed: OpenAI-compatible STT returned HTTP 401: upstream rejected bearer [redacted]',
})
expect(JSON.stringify(ctx.body)).not.toContain('server-secret')
})
})
describe('route registration ordering', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.doUnmock('../../packages/server/src/routes/index')
})
it('mounts protected STT routes after requireAuth', async () => {
const ttsPublicMiddleware = async () => {}
const ttsProtectedMiddleware = async () => {}
const sttProtectedMiddleware = async () => {}
vi.doMock('../../packages/server/src/routes/hermes/tts', () => ({
ttsRoutes: { routes: vi.fn(() => ttsPublicMiddleware) },
ttsProtectedRoutes: { routes: vi.fn(() => ttsProtectedMiddleware) },
}))
vi.doMock('../../packages/server/src/routes/hermes/stt', () => ({
sttProtectedRoutes: { routes: vi.fn(() => sttProtectedMiddleware) },
}))
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(sttProtectedMiddleware)).toBeGreaterThan(mountedMiddleware.indexOf(requireAuth))
expect(mountedMiddleware.indexOf(sttProtectedMiddleware)).toBeGreaterThan(mountedMiddleware.indexOf(ttsProtectedMiddleware))
})
})