Skip to content

Commit 30be576

Browse files
committed
feat: add api for chat streaming
1 parent 20cca9f commit 30be576

File tree

3 files changed

+487
-0
lines changed

3 files changed

+487
-0
lines changed
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import type { Request, Response } from 'express'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
import type Context from '@/types/express/context'
5+
6+
const mocks = vi.hoisted(() => ({
7+
getAuthenticatedContext: vi.fn(),
8+
getLdFlagValue: vi.fn(),
9+
langfuseClient: {
10+
prompt: {
11+
get: vi.fn(),
12+
},
13+
},
14+
startActiveObservation: vi.fn(),
15+
streamText: vi.fn(),
16+
}))
17+
18+
vi.mock('../middleware/authentication', () => ({
19+
getAuthenticatedContext: mocks.getAuthenticatedContext,
20+
}))
21+
22+
vi.mock('@/helpers/launch-darkly', () => ({
23+
getLdFlagValue: mocks.getLdFlagValue,
24+
}))
25+
26+
vi.mock('@/helpers/langfuse', () => ({
27+
langfuseClient: mocks.langfuseClient,
28+
}))
29+
30+
vi.mock('@langfuse/tracing', () => ({
31+
startActiveObservation: mocks.startActiveObservation,
32+
}))
33+
34+
vi.mock('ai', () => ({
35+
convertToModelMessages: vi.fn((msgs) => msgs),
36+
smoothStream: vi.fn(() => ({})),
37+
streamText: mocks.streamText,
38+
}))
39+
40+
vi.mock('@/helpers/logger', () => ({
41+
default: {
42+
info: vi.fn(),
43+
error: vi.fn(),
44+
},
45+
}))
46+
47+
vi.mock('@/helpers/pair', () => ({
48+
model: {},
49+
MODEL_TYPE: 'test-model',
50+
}))
51+
52+
vi.mock('@/config/app', () => ({
53+
default: {
54+
appEnv: 'test',
55+
},
56+
}))
57+
58+
// Helper function to get and execute the POST handler from the chat router
59+
async function executeChatPostHandler(
60+
req: Partial<Request>,
61+
res: Partial<Response>,
62+
) {
63+
const chatModule = await import('../chat')
64+
const router = chatModule.default
65+
66+
// Extract the POST handler
67+
const postHandler = (router as any).stack.find(
68+
(layer: any) => layer.route?.methods?.post,
69+
)?.route?.stack[0]?.handle
70+
71+
if (!postHandler) {
72+
throw new Error('POST handler not found in chat router')
73+
}
74+
75+
return postHandler(req, res)
76+
}
77+
78+
describe('Chat Route Authentication', () => {
79+
let mockReq: Partial<Request>
80+
let mockRes: Partial<Response>
81+
82+
beforeEach(() => {
83+
mockReq = {
84+
body: {
85+
messages: [
86+
{
87+
role: 'user',
88+
parts: [{ type: 'text', text: 'Hello' }],
89+
},
90+
],
91+
},
92+
context: {
93+
currentUser: {
94+
id: 'test-user-id',
95+
96+
} as any,
97+
isAdminOperation: false,
98+
} as any,
99+
} as Partial<Request>
100+
101+
mockRes = {
102+
status: vi.fn().mockReturnThis(),
103+
json: vi.fn(),
104+
headersSent: false,
105+
end: vi.fn(),
106+
} as Partial<Response>
107+
108+
// Reset mocks
109+
vi.clearAllMocks()
110+
})
111+
112+
afterEach(() => {
113+
vi.restoreAllMocks()
114+
})
115+
116+
describe('Authentication Requirements', () => {
117+
it('should call getAuthenticatedContext on every request', async () => {
118+
const mockContext: Context = {
119+
req: mockReq as Request,
120+
res: mockRes as Response,
121+
currentUser: {
122+
id: 'test-user-id',
123+
124+
} as any,
125+
isAdminOperation: false,
126+
}
127+
128+
mocks.getAuthenticatedContext.mockReturnValueOnce(mockContext)
129+
mocks.getLdFlagValue.mockResolvedValueOnce({
130+
chatPrompt: 'aids-chat-v0',
131+
version: 'production',
132+
})
133+
mocks.langfuseClient.prompt.get.mockResolvedValueOnce({
134+
prompt: 'test prompt',
135+
})
136+
137+
// Mock streamText to return a mock result
138+
const mockResult = {
139+
pipeUIMessageStreamToResponse: vi.fn(),
140+
}
141+
mocks.startActiveObservation.mockImplementationOnce(
142+
async (name, callback) => {
143+
const mockTrace = {
144+
updateTrace: vi.fn(),
145+
startObservation: vi.fn(() => ({
146+
update: vi.fn(),
147+
})),
148+
update: vi.fn(),
149+
traceId: 'test-trace-id',
150+
}
151+
return await callback(mockTrace)
152+
},
153+
)
154+
mocks.streamText.mockResolvedValueOnce(mockResult)
155+
156+
await executeChatPostHandler(mockReq, mockRes)
157+
158+
expect(mocks.getAuthenticatedContext).toHaveBeenCalledWith(mockReq)
159+
})
160+
161+
it('should throw error when user is not authenticated', async () => {
162+
mocks.getAuthenticatedContext.mockImplementationOnce(() => {
163+
throw new Error('User must be authenticated')
164+
})
165+
166+
await expect(executeChatPostHandler(mockReq, mockRes)).rejects.toThrow(
167+
'User must be authenticated',
168+
)
169+
170+
expect(mocks.getAuthenticatedContext).toHaveBeenCalledWith(mockReq)
171+
// Should not reach getLdFlagValue since authentication failed
172+
expect(mocks.getLdFlagValue).not.toHaveBeenCalled()
173+
})
174+
175+
it('should use authenticated user email for feature flag lookup', async () => {
176+
const mockContext: Context = {
177+
req: mockReq as Request,
178+
res: mockRes as Response,
179+
currentUser: {
180+
id: 'test-user-id',
181+
182+
} as any,
183+
isAdminOperation: false,
184+
}
185+
186+
mocks.getAuthenticatedContext.mockReturnValueOnce(mockContext)
187+
mocks.getLdFlagValue.mockResolvedValueOnce({
188+
chatPrompt: 'aids-chat-v0',
189+
version: 'production',
190+
})
191+
mocks.langfuseClient.prompt.get.mockResolvedValueOnce({
192+
prompt: 'test prompt',
193+
})
194+
195+
const mockResult = {
196+
pipeUIMessageStreamToResponse: vi.fn(),
197+
}
198+
mocks.startActiveObservation.mockImplementationOnce(
199+
async (name, callback) => {
200+
const mockTrace = {
201+
updateTrace: vi.fn(),
202+
startObservation: vi.fn(() => ({
203+
update: vi.fn(),
204+
})),
205+
update: vi.fn(),
206+
traceId: 'test-trace-id',
207+
}
208+
return await callback(mockTrace)
209+
},
210+
)
211+
mocks.streamText.mockResolvedValueOnce(mockResult)
212+
213+
await executeChatPostHandler(mockReq, mockRes)
214+
215+
expect(mocks.getLdFlagValue).toHaveBeenCalledWith(
216+
'ai-builder-prompt-config',
217+
218+
expect.any(Object),
219+
)
220+
})
221+
222+
it('should validate request body before processing', async () => {
223+
const mockContext: Context = {
224+
req: mockReq as Request,
225+
res: mockRes as Response,
226+
currentUser: {
227+
id: 'test-user-id',
228+
229+
} as any,
230+
isAdminOperation: false,
231+
}
232+
233+
// Invalid request body (empty messages)
234+
mockReq.body = {
235+
messages: [],
236+
}
237+
238+
mocks.getAuthenticatedContext.mockReturnValueOnce(mockContext)
239+
mocks.getLdFlagValue.mockResolvedValueOnce({
240+
chatPrompt: 'aids-chat-v0',
241+
version: 'production',
242+
})
243+
244+
await executeChatPostHandler(mockReq, mockRes)
245+
246+
expect(mockRes.status).toHaveBeenCalledWith(400)
247+
expect(mockRes.json).toHaveBeenCalledWith({
248+
error: 'Messages array is required',
249+
})
250+
})
251+
})
252+
253+
describe('Admin User Access', () => {
254+
it('should allow admin users to access the endpoint', async () => {
255+
const mockContext: Context = {
256+
req: mockReq as Request,
257+
res: mockRes as Response,
258+
currentUser: {
259+
id: 'admin-user-id',
260+
261+
} as any,
262+
isAdminOperation: true,
263+
}
264+
265+
mocks.getAuthenticatedContext.mockReturnValueOnce(mockContext)
266+
mocks.getLdFlagValue.mockResolvedValueOnce({
267+
chatPrompt: 'aids-chat-v0',
268+
version: 'production',
269+
})
270+
mocks.langfuseClient.prompt.get.mockResolvedValueOnce({
271+
prompt: 'test prompt',
272+
})
273+
274+
const mockResult = {
275+
pipeUIMessageStreamToResponse: vi.fn(),
276+
}
277+
mocks.startActiveObservation.mockImplementationOnce(
278+
async (name, callback) => {
279+
const mockTrace = {
280+
updateTrace: vi.fn(),
281+
startObservation: vi.fn(() => ({
282+
update: vi.fn(),
283+
})),
284+
update: vi.fn(),
285+
traceId: 'test-trace-id',
286+
}
287+
return await callback(mockTrace)
288+
},
289+
)
290+
mocks.streamText.mockResolvedValueOnce(mockResult)
291+
292+
await executeChatPostHandler(mockReq, mockRes)
293+
294+
expect(mocks.getAuthenticatedContext).toHaveBeenCalledWith(mockReq)
295+
expect(mocks.getLdFlagValue).toHaveBeenCalledWith(
296+
'ai-builder-prompt-config',
297+
298+
expect.any(Object),
299+
)
300+
})
301+
})
302+
})

0 commit comments

Comments
 (0)