profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
audio.test.ts 3,812 bytes
1 import { mkdtemp, rm } from 'node:fs/promises'
2 import { tmpdir } from 'node:os'
3 import path from 'node:path'
4 import type { FastifyInstance } from 'fastify'
5 import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 import { CATEGORY_IDS, type AnswerResponse, type Session } from '../../shared/types'
7 import { buildApp } from '../app'
8 import { createLlmMock } from '../providers/llmMock'
9 import { createSttMock } from '../providers/sttMock'
10 import { SessionStore } from '../store/sessionStore'
11
12 function buildMultipartPayload(
13 fileBuffer: Buffer,
14 filename: string,
15 mimeType: string,
16 ): { body: Buffer; contentType: string } {
17 const boundary = '----voicetaskTestBoundary1234567890'
18 const parts = [
19 Buffer.from(`--${boundary}\r\n`),
20 Buffer.from(`Content-Disposition: form-data; name="audio"; filename="${filename}"\r\n`),
21 Buffer.from(`Content-Type: ${mimeType}\r\n\r\n`),
22 fileBuffer,
23 Buffer.from(`\r\n--${boundary}--\r\n`),
24 ]
25 return { body: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` }
26 }
27
28 describe('audio route', () => {
29 let baseDir: string
30 let app: FastifyInstance
31
32 beforeEach(async () => {
33 baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-audio-'))
34 app = buildApp({ store: new SessionStore(baseDir), llm: createLlmMock(), stt: createSttMock() })
35 })
36
37 afterEach(async () => {
38 await app.close()
39 await rm(baseDir, { recursive: true, force: true })
40 })
41
42 async function createSession(): Promise<Session> {
43 return app
44 .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
45 .then((r) => r.json<Session>())
46 }
47
48 it('transcribes an uploaded clip, stores a segment, and triggers a turn', async () => {
49 const session = await createSession()
50 const { body, contentType } = buildMultipartPayload(
51 Buffer.from('we want push notifications', 'utf8'),
52 'clip.webm',
53 'text/plain',
54 )
55
56 const res = await app.inject({
57 method: 'POST',
58 url: `/api/sessions/${session.id}/audio`,
59 headers: { 'content-type': contentType },
60 payload: body,
61 })
62
63 expect(res.statusCode).toBe(200)
64 const responseBody = res.json<AnswerResponse>()
65 expect(responseBody.segment.speaker).toBe('user')
66 expect(responseBody.segment.text).toBe('we want push notifications')
67 expect(responseBody.turn.coverage[CATEGORY_IDS[0]]).toBe('clear')
68
69 const reloaded = await app
70 .inject({ method: 'GET', url: `/api/sessions/${session.id}` })
71 .then((r) => r.json<Session>())
72 expect(reloaded.segments).toHaveLength(2)
73 expect(reloaded.segments[0]).toMatchObject({ speaker: 'user', text: 'we want push notifications' })
74 })
75
76 it('rejects an empty audio upload with a 4xx and stores nothing', async () => {
77 const session = await createSession()
78 const { body, contentType } = buildMultipartPayload(Buffer.alloc(0), 'clip.webm', 'audio/webm')
79
80 const res = await app.inject({
81 method: 'POST',
82 url: `/api/sessions/${session.id}/audio`,
83 headers: { 'content-type': contentType },
84 payload: body,
85 })
86
87 expect(res.statusCode).toBeGreaterThanOrEqual(400)
88 expect(res.statusCode).toBeLessThan(500)
89
90 const reloaded = await app
91 .inject({ method: 'GET', url: `/api/sessions/${session.id}` })
92 .then((r) => r.json<Session>())
93 expect(reloaded.segments).toHaveLength(0)
94 })
95
96 it('returns 404 for an unknown session', async () => {
97 const { body, contentType } = buildMultipartPayload(Buffer.from('hello'), 'clip.webm', 'text/plain')
98 const res = await app.inject({
99 method: 'POST',
100 url: '/api/sessions/nonexistent/audio',
101 headers: { 'content-type': contentType },
102 payload: body,
103 })
104 expect(res.statusCode).toBe(404)
105 })
106 })
107