Skip to content

Commit 8d9875c

Browse files
authored
improvement(voice): add dictation to organization chat and search (#7737)
* improvement(voice): add dictation to organization chat and search * fix(voice): preserve manual edits during dictation
1 parent 2ac7faa commit 8d9875c

15 files changed

Lines changed: 1024 additions & 328 deletions

File tree

Lines changed: 253 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,296 @@
1-
/**
2-
* @vitest-environment node
3-
*/
1+
/** @vitest-environment node */
2+
import * as workspaceAuthz from '@sim/platform-authz/workspace'
43
import {
54
authMockFns,
65
createMockRequest,
6+
dbChainMockFns,
7+
envFlagsMockFns,
78
resetDbChainMock,
9+
resetEnvFlagsMock,
810
resetEnvMock,
911
setEnv,
12+
setEnvFlags,
1013
} from '@sim/testing'
11-
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
12-
13-
const {
14-
mockRecordUsage,
15-
mockVerifyWorkspaceMembership,
16-
mockResolveBillingAttribution,
17-
mockCheckAttributedUsageLimits,
18-
mockToBillingContext,
19-
mockCheckAndBillPayerOverageThreshold,
20-
} = vi.hoisted(() => ({
21-
mockRecordUsage: vi.fn(),
22-
mockVerifyWorkspaceMembership: vi.fn(),
23-
mockResolveBillingAttribution: vi.fn(),
24-
mockCheckAttributedUsageLimits: vi.fn(),
25-
mockToBillingContext: vi.fn(),
26-
mockCheckAndBillPayerOverageThreshold: vi.fn(),
27-
}))
14+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2815

29-
vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mockRecordUsage }))
16+
const mocks = vi.hoisted(() => ({
17+
recordUsage: vi.fn(),
18+
resolveBilling: vi.fn(),
19+
resolveOrganizationBilling: vi.fn(),
20+
checkUsage: vi.fn(),
21+
toBillingContext: vi.fn(),
22+
billOverage: vi.fn(),
23+
rateCheck: vi.fn(),
24+
organizationConfig: vi.fn(),
25+
workspaceContext: vi.fn(),
26+
}))
3027

28+
vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mocks.recordUsage }))
3129
vi.mock('@/lib/billing/core/billing-attribution', () => ({
32-
resolveBillingAttribution: mockResolveBillingAttribution,
33-
checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
34-
toBillingContext: mockToBillingContext,
30+
resolveBillingAttribution: mocks.resolveBilling,
31+
resolveOrganizationBillingAttribution: mocks.resolveOrganizationBilling,
32+
checkAttributedUsageLimits: mocks.checkUsage,
33+
toBillingContext: mocks.toBillingContext,
3534
}))
36-
3735
vi.mock('@/lib/billing/threshold-billing', () => ({
38-
checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold,
39-
}))
40-
41-
vi.mock('@/app/api/workflows/utils', () => ({
42-
verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
36+
checkAndBillPayerOverageThreshold: mocks.billOverage,
4337
}))
44-
4538
vi.mock('@/lib/core/rate-limiter', () => ({
4639
RateLimiter: class {
47-
checkRateLimitDirect = vi.fn().mockResolvedValue({ allowed: true })
40+
checkRateLimitDirect = mocks.rateCheck
4841
},
4942
}))
43+
vi.mock('@/lib/permission-groups/resolve.server', () => ({
44+
getUserPermissionConfigForOrganization: mocks.organizationConfig,
45+
}))
46+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
47+
resolveActiveWorkspaceApplicationContext: mocks.workspaceContext,
48+
}))
5049

50+
import { OrchestrationError } from '@/lib/core/orchestration/types'
51+
import { createSpeechToken } from '@/lib/speech/application/create-token'
5152
import { POST } from '@/app/api/speech/token/route'
5253

53-
const mockGetSession = authMockFns.mockGetSession
54+
const permission = vi.spyOn(workspaceAuthz, 'resolveEffectiveWorkspacePermission')
55+
const principal = { kind: 'session', userId: 'member-1', sessionId: 'session-1' } as const
56+
const billingEntity = { type: 'organization', id: 'org-1' } as const
57+
const billingPeriod = { start: new Date('2026-07-01'), end: new Date('2026-08-01') }
5458

5559
beforeEach(() => {
5660
vi.clearAllMocks()
5761
resetDbChainMock()
5862
setEnv({ ELEVENLABS_API_KEY: 'test-key' })
59-
mockGetSession.mockResolvedValue({ user: { id: 'member-1' } })
60-
mockRecordUsage.mockResolvedValue(undefined)
61-
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
62-
mockResolveBillingAttribution.mockImplementation(
63-
({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) => ({
64-
actorUserId,
65-
workspaceId,
66-
billingEntity: { type: 'organization', id: 'org-1' },
67-
})
68-
)
69-
mockToBillingContext.mockImplementation(
70-
(attribution: { billingEntity: { type: 'organization' | 'user'; id: string } }) => ({
71-
billingEntity: attribution.billingEntity,
72-
billingPeriod: {
73-
start: new Date('2026-07-01T00:00:00.000Z'),
74-
end: new Date('2026-08-01T00:00:00.000Z'),
75-
},
76-
})
77-
)
78-
mockVerifyWorkspaceMembership.mockResolvedValue('admin')
79-
global.fetch = vi.fn().mockResolvedValue({
80-
ok: true,
81-
json: async () => ({ token: 'tok-123' }),
82-
// double-cast-allowed: minimal fetch stub for the ElevenLabs token call
83-
}) as unknown as typeof fetch
63+
setEnvFlags({ isBillingEnabled: true })
64+
authMockFns.mockGetSession.mockResolvedValue({
65+
user: { id: principal.userId },
66+
session: { id: principal.sessionId },
67+
})
68+
permission.mockResolvedValue('read')
69+
mocks.workspaceContext.mockImplementation(async (workspaceId: string) => ({
70+
workspaceId,
71+
workspaceOrganizationId: null,
72+
allowPersonalApiKeys: true,
73+
billedAccountUserId: 'owner-1',
74+
}))
75+
mocks.organizationConfig.mockResolvedValue(null)
76+
dbChainMockFns.limit.mockResolvedValue([{ role: 'member' }])
77+
mocks.recordUsage.mockResolvedValue(undefined)
78+
mocks.billOverage.mockResolvedValue(undefined)
79+
mocks.rateCheck.mockResolvedValue({ allowed: true })
80+
mocks.checkUsage.mockResolvedValue({ isExceeded: false })
81+
mocks.resolveBilling.mockImplementation(async (input) => ({ ...input, billingEntity }))
82+
mocks.resolveOrganizationBilling.mockImplementation(async (input) => ({
83+
...input,
84+
workspaceId: null,
85+
billedAccountUserId: 'owner-1',
86+
billingEntity,
87+
}))
88+
mocks.toBillingContext.mockReturnValue({ billingEntity, billingPeriod })
89+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ token: 'tok-123' })))
8490
})
8591

86-
afterAll(() => {
92+
afterEach(() => {
93+
vi.unstubAllGlobals()
8794
resetDbChainMock()
8895
resetEnvMock()
96+
resetEnvFlagsMock()
8997
})
9098

91-
describe('POST /api/speech/token — usage attribution', () => {
92-
it('editor voice: bills the session user and stamps the verified workspace', async () => {
93-
const res = await POST(createMockRequest('POST', { workspaceId: 'ws-1' }))
99+
describe('POST /api/speech/token', () => {
100+
it.each(['read', 'write', 'admin'] as const)(
101+
'allows workspace %s members and bills the acting user',
102+
async (role) => {
103+
permission.mockResolvedValue(role)
104+
envFlagsMockFns.getCostMultiplier.mockReturnValue(2)
105+
const response = await POST(createMockRequest('POST', { workspaceId: 'ws-1' }))
94106

95-
expect(res.status).toBe(200)
96-
expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('member-1', 'ws-1')
97-
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
98-
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
99-
userId: 'member-1',
100-
workspaceId: 'ws-1',
101-
})
102-
expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
103-
actorUserId: 'member-1',
104-
workspaceId: 'ws-1',
107+
expect(response.status).toBe(200)
108+
expect(await response.json()).toEqual({ token: 'tok-123' })
109+
expect(permission).toHaveBeenCalledWith('member-1', 'ws-1', null, undefined, {
110+
forUpdate: undefined,
111+
})
112+
expect(mocks.resolveBilling).toHaveBeenCalledWith({
113+
actorUserId: 'member-1',
114+
workspaceId: 'ws-1',
115+
})
116+
expect(mocks.recordUsage).toHaveBeenCalledWith({
117+
userId: 'member-1',
118+
workspaceId: 'ws-1',
119+
billingEntity,
120+
billingPeriod,
121+
entries: [
122+
{
123+
category: 'fixed',
124+
source: 'voice-input',
125+
description: 'Voice input session (3 min)',
126+
cost: 0.048,
127+
sourceReference: expect.stringMatching(/^voice-input:[a-f0-9]{64}$/),
128+
},
129+
],
130+
})
131+
expect(mocks.billOverage).toHaveBeenCalledWith(billingEntity)
132+
}
133+
)
134+
135+
it.each(['member', 'admin', 'owner'])(
136+
'allows organization %s members without inventing a workspace',
137+
async (role) => {
138+
dbChainMockFns.limit.mockResolvedValue([{ role }])
139+
const response = await POST(createMockRequest('POST', { organizationId: 'org-1' }))
140+
141+
expect(response.status).toBe(200)
142+
expect(mocks.resolveOrganizationBilling).toHaveBeenCalledWith({
143+
actorUserId: 'member-1',
144+
organizationId: 'org-1',
145+
})
146+
expect(mocks.resolveBilling).not.toHaveBeenCalled()
147+
expect(mocks.workspaceContext).not.toHaveBeenCalled()
148+
expect(mocks.recordUsage.mock.calls[0][0]).toMatchObject({
149+
userId: 'member-1',
150+
billingEntity,
151+
billingPeriod,
152+
})
153+
expect(mocks.recordUsage.mock.calls[0][0]).not.toHaveProperty('workspaceId')
154+
}
155+
)
156+
157+
it('authenticates before reading an oversized body', async () => {
158+
authMockFns.mockGetSession.mockResolvedValue(null)
159+
const response = await POST(createMockRequest('POST', { workspaceId: 'x'.repeat(64 * 1024) }))
160+
expect(response.status).toBe(401)
161+
expect(mocks.rateCheck).not.toHaveBeenCalled()
162+
expect(mocks.workspaceContext).not.toHaveBeenCalled()
163+
})
164+
165+
it('caps authenticated bodies before protected loading', async () => {
166+
const response = await POST(createMockRequest('POST', { workspaceId: 'x'.repeat(64 * 1024) }))
167+
expect(response.status).toBe(413)
168+
expect(mocks.workspaceContext).not.toHaveBeenCalled()
169+
expect(mocks.recordUsage).not.toHaveBeenCalled()
170+
})
171+
172+
it.each([
173+
{},
174+
{ workspaceId: 'ws-1', organizationId: 'org-1' },
175+
{ organizationId: '' },
176+
{ workspaceId: 1 },
177+
])('rejects absent, ambiguous or invalid scope %j', async (body) => {
178+
const response = await POST(createMockRequest('POST', body))
179+
expect(response.status).toBe(400)
180+
expect(mocks.workspaceContext).not.toHaveBeenCalled()
181+
expect(mocks.resolveOrganizationBilling).not.toHaveBeenCalled()
182+
expect(fetch).not.toHaveBeenCalled()
183+
})
184+
185+
it('conceals a workspace the caller cannot access', async () => {
186+
permission.mockResolvedValue(null)
187+
const response = await POST(createMockRequest('POST', { workspaceId: 'ws-other' }))
188+
expect(response.status).toBe(400)
189+
expect(await response.json()).toMatchObject({
190+
error: 'Workspace or organization context is required.',
105191
})
106-
expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
107-
type: 'organization',
108-
id: 'org-1',
192+
expect(mocks.resolveBilling).not.toHaveBeenCalled()
193+
expect(fetch).not.toHaveBeenCalled()
194+
})
195+
196+
it('rejects removed organization members before billing or token creation', async () => {
197+
dbChainMockFns.limit.mockResolvedValue([])
198+
const response = await POST(createMockRequest('POST', { organizationId: 'org-other' }))
199+
expect(response.status).toBe(400)
200+
expect(mocks.resolveOrganizationBilling).not.toHaveBeenCalled()
201+
expect(fetch).not.toHaveBeenCalled()
202+
})
203+
204+
it('rejects inactive workspaces before billing', async () => {
205+
mocks.workspaceContext.mockRejectedValue(
206+
new OrchestrationError('not_found', 'Workspace not found')
207+
)
208+
const response = await POST(createMockRequest('POST', { workspaceId: 'ws-archived' }))
209+
expect(response.status).toBe(400)
210+
expect(mocks.resolveBilling).not.toHaveBeenCalled()
211+
})
212+
213+
it('rates by actor with the existing bucket and retry header before parsing', async () => {
214+
mocks.rateCheck.mockResolvedValue({ allowed: false, retryAfterMs: 1501 })
215+
const response = await POST(createMockRequest('POST', {}))
216+
expect(response.status).toBe(429)
217+
expect(response.headers.get('Retry-After')).toBe('2')
218+
expect(mocks.rateCheck).toHaveBeenCalledWith('stt-token:user:member-1', {
219+
maxTokens: 30,
220+
refillRate: 3,
221+
refillIntervalMs: 72000,
109222
})
223+
expect(mocks.resolveOrganizationBilling).not.toHaveBeenCalled()
110224
})
111225

112-
it('editor voice: rejects an unverified workspace id (requires an attributable workspace)', async () => {
113-
mockVerifyWorkspaceMembership.mockResolvedValue(null)
226+
it('preserves the rate exemption when billing is disabled', async () => {
227+
setEnvFlags({ isBillingEnabled: false })
228+
const response = await POST(createMockRequest('POST', { organizationId: 'org-1' }))
229+
expect(response.status).toBe(200)
230+
expect(mocks.rateCheck).not.toHaveBeenCalled()
231+
})
114232

115-
const res = await POST(createMockRequest('POST', { workspaceId: 'ws-not-mine' }))
233+
it.each(['actor', 'payer', 'member'])(
234+
'enforces the %s usage cap before contacting the provider',
235+
async (scope) => {
236+
mocks.checkUsage.mockResolvedValue({ isExceeded: true, message: 'Usage cap reached', scope })
237+
const response = await POST(createMockRequest('POST', { organizationId: 'org-1' }))
238+
expect(response.status).toBe(402)
239+
expect(await response.json()).toMatchObject({ error: 'Usage cap reached', scope })
240+
expect(fetch).not.toHaveBeenCalled()
241+
expect(mocks.recordUsage).not.toHaveBeenCalled()
242+
}
243+
)
116244

117-
expect(res.status).toBe(400)
118-
expect(mockRecordUsage).not.toHaveBeenCalled()
245+
it('does not conceal membership infrastructure failures as missing membership', async () => {
246+
dbChainMockFns.limit.mockRejectedValue(new Error('database unavailable'))
247+
const response = await POST(createMockRequest('POST', { organizationId: 'org-1' }))
248+
expect(response.status).toBe(500)
249+
expect(mocks.resolveOrganizationBilling).not.toHaveBeenCalled()
119250
})
120251

121-
it('rejects an oversized body before any auth/billing work runs', async () => {
122-
const oversizedBody = { workspaceId: 'x'.repeat(64 * 1024) }
123-
const res = await POST(createMockRequest('POST', oversizedBody))
252+
it('does not issue tokens after billing attribution fails', async () => {
253+
mocks.resolveOrganizationBilling.mockRejectedValue(new Error('billing unavailable'))
254+
const response = await POST(createMockRequest('POST', { organizationId: 'org-1' }))
255+
expect(response.status).toBe(500)
256+
expect(fetch).not.toHaveBeenCalled()
257+
})
258+
259+
it('preserves service-not-configured and upstream errors', async () => {
260+
setEnv({ ELEVENLABS_API_KEY: '' })
261+
expect((await POST(createMockRequest('POST', { organizationId: 'org-1' }))).status).toBe(503)
262+
setEnv({ ELEVENLABS_API_KEY: 'key' })
263+
vi.mocked(fetch).mockResolvedValue(
264+
Response.json({ detail: 'Provider unavailable' }, { status: 503 })
265+
)
266+
const response = await POST(createMockRequest('POST', { organizationId: 'org-1' }))
267+
expect(response.status).toBe(502)
268+
expect(await response.json()).toMatchObject({ error: 'Provider unavailable' })
269+
expect(mocks.recordUsage).not.toHaveBeenCalled()
270+
})
271+
272+
it.each(['recordUsage', 'billOverage'] as const)(
273+
'keeps an issued token available when %s fails',
274+
async (failure) => {
275+
mocks[failure].mockRejectedValue(new Error('billing write failed'))
276+
const response = await POST(createMockRequest('POST', { organizationId: 'org-1' }))
277+
expect(response.status).toBe(200)
278+
expect(await response.json()).toEqual({ token: 'tok-123' })
279+
if (failure === 'recordUsage') expect(mocks.billOverage).not.toHaveBeenCalled()
280+
}
281+
)
124282

125-
expect(res.status).toBe(413)
126-
expect(mockGetSession).not.toHaveBeenCalled()
127-
expect(mockRecordUsage).not.toHaveBeenCalled()
283+
it('rejects non-session principals before any protected loading', async () => {
284+
for (const input of [{ workspaceId: 'ws-1' }, { organizationId: 'org-1' }]) {
285+
await expect(
286+
createSpeechToken.execute({
287+
principal: { kind: 'personal_api_key', userId: 'member-1', keyId: 'key-1' },
288+
input,
289+
})
290+
).rejects.toThrow('cannot perform operation speech.token.create')
291+
}
292+
expect(mocks.workspaceContext).not.toHaveBeenCalled()
293+
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
294+
expect(fetch).not.toHaveBeenCalled()
128295
})
129296
})

0 commit comments

Comments
 (0)