Skip to content

Commit ac63621

Browse files
committed
add /api/v1/agent-runs endpoint
1 parent a781257 commit ac63621

File tree

3 files changed

+512
-0
lines changed

3 files changed

+512
-0
lines changed
Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
import { TEST_USER_ID } from '@codebuff/common/old-constants'
2+
import {
3+
afterEach,
4+
beforeEach,
5+
describe,
6+
expect,
7+
mock,
8+
spyOn,
9+
test,
10+
} from 'bun:test'
11+
import { NextRequest } from 'next/server'
12+
13+
import { agentRunsPost } from '../agent-runs'
14+
15+
import type {
16+
GetUserInfoFromApiKeyFn,
17+
GetUserInfoFromApiKeyOutput,
18+
} from '@codebuff/common/types/contracts/database'
19+
import type { Logger } from '@codebuff/common/types/contracts/logger'
20+
21+
describe('/api/v1/agent-runs POST endpoint', () => {
22+
const mockUserData: Record<
23+
string,
24+
NonNullable<Awaited<GetUserInfoFromApiKeyOutput<'id'>>>
25+
> = {
26+
'test-api-key-123': {
27+
id: 'user-123',
28+
},
29+
'test-api-key-456': {
30+
id: 'user-456',
31+
},
32+
'test-api-key-test': {
33+
id: TEST_USER_ID,
34+
},
35+
}
36+
37+
const mockGetUserInfoFromApiKey: GetUserInfoFromApiKeyFn = async ({
38+
apiKey,
39+
}) => {
40+
const userData = mockUserData[apiKey]
41+
if (!userData) {
42+
return null
43+
}
44+
return { id: userData.id } as any
45+
}
46+
47+
let mockLogger: Logger = {
48+
error: () => {},
49+
warn: () => {},
50+
info: () => {},
51+
debug: () => {},
52+
}
53+
54+
let mockDbInsert: any
55+
56+
beforeEach(async () => {
57+
// Mock the db.insert chain
58+
mockDbInsert = {
59+
values: async () => {},
60+
}
61+
62+
mockLogger = {
63+
error: mock(() => {}),
64+
warn: mock(() => {}),
65+
info: mock(() => {}),
66+
debug: mock(() => {}),
67+
}
68+
69+
const dbModule = await import('@codebuff/common/db')
70+
spyOn(dbModule.default, 'insert').mockReturnValue(mockDbInsert)
71+
})
72+
73+
afterEach(() => {
74+
mock.restore()
75+
})
76+
77+
describe('Authentication', () => {
78+
test('returns 401 when Authorization header is missing', async () => {
79+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
80+
method: 'POST',
81+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
82+
})
83+
const response = await agentRunsPost({
84+
req,
85+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
86+
logger: mockLogger,
87+
})
88+
89+
expect(response.status).toBe(401)
90+
const body = await response.json()
91+
expect(body).toEqual({ error: 'Missing or invalid Authorization header' })
92+
})
93+
94+
test('returns 401 when Authorization header is malformed', async () => {
95+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
96+
method: 'POST',
97+
headers: { Authorization: 'InvalidFormat' },
98+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
99+
})
100+
const response = await agentRunsPost({
101+
req,
102+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
103+
logger: mockLogger,
104+
})
105+
106+
expect(response.status).toBe(401)
107+
const body = await response.json()
108+
expect(body).toEqual({ error: 'Missing or invalid Authorization header' })
109+
})
110+
111+
test('extracts API key from x-codebuff-api-key header', async () => {
112+
const apiKey = 'test-api-key-123'
113+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
114+
method: 'POST',
115+
headers: { 'x-codebuff-api-key': apiKey },
116+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
117+
})
118+
119+
const response = await agentRunsPost({
120+
req,
121+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
122+
logger: mockLogger,
123+
})
124+
expect(response.status).toBe(200)
125+
const body = await response.json()
126+
expect(body).toHaveProperty('runId')
127+
})
128+
129+
test('extracts API key from Bearer token in Authorization header', async () => {
130+
const apiKey = 'test-api-key-123'
131+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
132+
method: 'POST',
133+
headers: { Authorization: `Bearer ${apiKey}` },
134+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
135+
})
136+
137+
const response = await agentRunsPost({
138+
req,
139+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
140+
logger: mockLogger,
141+
})
142+
expect(response.status).toBe(200)
143+
const body = await response.json()
144+
expect(body).toHaveProperty('runId')
145+
})
146+
147+
test('returns 404 when API key is invalid', async () => {
148+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
149+
method: 'POST',
150+
headers: { Authorization: 'Bearer invalid-key' },
151+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
152+
})
153+
154+
const response = await agentRunsPost({
155+
req,
156+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
157+
logger: mockLogger,
158+
})
159+
expect(response.status).toBe(404)
160+
const body = await response.json()
161+
expect(body).toEqual({ error: 'Invalid API key or user not found' })
162+
})
163+
})
164+
165+
describe('Request body validation', () => {
166+
test('returns 400 when body is not valid JSON', async () => {
167+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
168+
method: 'POST',
169+
headers: { Authorization: 'Bearer test-api-key-123' },
170+
body: 'not json',
171+
})
172+
173+
const response = await agentRunsPost({
174+
req,
175+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
176+
logger: mockLogger,
177+
})
178+
expect(response.status).toBe(400)
179+
const body = await response.json()
180+
expect(body).toEqual({ error: 'Invalid JSON in request body' })
181+
})
182+
183+
test('returns 400 when action field is missing', async () => {
184+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
185+
method: 'POST',
186+
headers: { Authorization: 'Bearer test-api-key-123' },
187+
body: JSON.stringify({ agentId: 'test-agent' }),
188+
})
189+
190+
const response = await agentRunsPost({
191+
req,
192+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
193+
logger: mockLogger,
194+
})
195+
expect(response.status).toBe(400)
196+
const body = await response.json()
197+
expect(body.error).toBe('Invalid request body')
198+
expect(body.details).toBeDefined()
199+
})
200+
201+
test('returns 400 when action is not START', async () => {
202+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
203+
method: 'POST',
204+
headers: { Authorization: 'Bearer test-api-key-123' },
205+
body: JSON.stringify({ action: 'STOP', agentId: 'test-agent' }),
206+
})
207+
208+
const response = await agentRunsPost({
209+
req,
210+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
211+
logger: mockLogger,
212+
})
213+
expect(response.status).toBe(400)
214+
const body = await response.json()
215+
expect(body.error).toBe('Invalid request body')
216+
expect(body.details).toBeDefined()
217+
})
218+
219+
test('returns 400 when agentId field is missing', async () => {
220+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
221+
method: 'POST',
222+
headers: { Authorization: 'Bearer test-api-key-123' },
223+
body: JSON.stringify({ action: 'START' }),
224+
})
225+
226+
const response = await agentRunsPost({
227+
req,
228+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
229+
logger: mockLogger,
230+
})
231+
expect(response.status).toBe(400)
232+
const body = await response.json()
233+
expect(body.error).toBe('Invalid request body')
234+
expect(body.details).toBeDefined()
235+
})
236+
237+
test('returns 400 when ancestorRunIds is not an array', async () => {
238+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
239+
method: 'POST',
240+
headers: { Authorization: 'Bearer test-api-key-123' },
241+
body: JSON.stringify({
242+
action: 'START',
243+
agentId: 'test-agent',
244+
ancestorRunIds: 'not-an-array',
245+
}),
246+
})
247+
248+
const response = await agentRunsPost({
249+
req,
250+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
251+
logger: mockLogger,
252+
})
253+
expect(response.status).toBe(400)
254+
const body = await response.json()
255+
expect(body.error).toBe('Invalid request body')
256+
expect(body.details).toBeDefined()
257+
})
258+
})
259+
260+
describe('Successful responses', () => {
261+
test('creates agent run and returns runId', async () => {
262+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
263+
method: 'POST',
264+
headers: { Authorization: 'Bearer test-api-key-123' },
265+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
266+
})
267+
268+
const response = await agentRunsPost({
269+
req,
270+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
271+
logger: mockLogger,
272+
})
273+
expect(response.status).toBe(200)
274+
const body = await response.json()
275+
expect(body).toHaveProperty('runId')
276+
expect(typeof body.runId).toBe('string')
277+
expect(body.runId.length).toBeGreaterThan(0)
278+
})
279+
280+
test('creates agent run with ancestorRunIds', async () => {
281+
const ancestorRunIds = ['run-1', 'run-2']
282+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
283+
method: 'POST',
284+
headers: { Authorization: 'Bearer test-api-key-123' },
285+
body: JSON.stringify({
286+
action: 'START',
287+
agentId: 'test-agent',
288+
ancestorRunIds,
289+
}),
290+
})
291+
292+
const response = await agentRunsPost({
293+
req,
294+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
295+
logger: mockLogger,
296+
})
297+
expect(response.status).toBe(200)
298+
const body = await response.json()
299+
expect(body).toHaveProperty('runId')
300+
})
301+
302+
test('creates agent run with empty ancestorRunIds', async () => {
303+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
304+
method: 'POST',
305+
headers: { Authorization: 'Bearer test-api-key-123' },
306+
body: JSON.stringify({
307+
action: 'START',
308+
agentId: 'test-agent',
309+
ancestorRunIds: [],
310+
}),
311+
})
312+
313+
const response = await agentRunsPost({
314+
req,
315+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
316+
logger: mockLogger,
317+
})
318+
expect(response.status).toBe(200)
319+
const body = await response.json()
320+
expect(body).toHaveProperty('runId')
321+
})
322+
323+
test('always generates new runId (never accepts from input)', async () => {
324+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
325+
method: 'POST',
326+
headers: { Authorization: 'Bearer test-api-key-123' },
327+
body: JSON.stringify({
328+
action: 'START',
329+
agentId: 'test-agent',
330+
runId: 'user-provided-run-id', // This should be ignored
331+
}),
332+
})
333+
334+
const response = await agentRunsPost({
335+
req,
336+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
337+
logger: mockLogger,
338+
})
339+
expect(response.status).toBe(200)
340+
const body = await response.json()
341+
expect(body.runId).not.toBe('user-provided-run-id')
342+
expect(typeof body.runId).toBe('string')
343+
})
344+
345+
test('returns test-run-id for TEST_USER_ID', async () => {
346+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
347+
method: 'POST',
348+
headers: { Authorization: 'Bearer test-api-key-test' },
349+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
350+
})
351+
352+
const response = await agentRunsPost({
353+
req,
354+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
355+
logger: mockLogger,
356+
})
357+
expect(response.status).toBe(200)
358+
const body = await response.json()
359+
expect(body.runId).toBe('test-run-id')
360+
})
361+
})
362+
363+
describe('Error handling', () => {
364+
test('returns 500 when database insert fails', async () => {
365+
// Override the beforeEach mock to throw an error
366+
const errorMockDbInsert = {
367+
values: async () => {
368+
throw new Error('Database error')
369+
},
370+
}
371+
372+
const dbModule = await import('@codebuff/common/db')
373+
spyOn(dbModule.default, 'insert').mockReturnValue(
374+
errorMockDbInsert as any
375+
)
376+
377+
const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', {
378+
method: 'POST',
379+
headers: { Authorization: 'Bearer test-api-key-123' },
380+
body: JSON.stringify({ action: 'START', agentId: 'test-agent' }),
381+
})
382+
383+
const response = await agentRunsPost({
384+
req,
385+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
386+
logger: mockLogger,
387+
})
388+
389+
expect(response.status).toBe(500)
390+
const body = await response.json()
391+
expect(body).toEqual({ error: 'Failed to create agent run' })
392+
expect(mockLogger.error).toHaveBeenCalled()
393+
})
394+
})
395+
})

0 commit comments

Comments
 (0)