Skip to content

Commit 922c2f9

Browse files
committed
feat(cli): build CodebuffApiClient to abstract web API details
1 parent 4706db5 commit 922c2f9

File tree

21 files changed

+1649
-417
lines changed

21 files changed

+1649
-417
lines changed

cli/src/__tests__/e2e/first-time-login.test.ts

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
pollLoginStatus,
66
type LoginUrlResponse,
77
} from '../../login/login-flow'
8+
import { createMockApiClient } from '../helpers/mock-api-client'
89

10+
import type { ApiResponse } from '../../utils/codebuff-api'
911
import type { Logger } from '@codebuff/common/types/contracts/logger'
1012

1113
type MockLogger = {
@@ -28,59 +30,70 @@ describe('First-Time Login Flow (helpers)', () => {
2830
expiresAt: '2025-12-31T23:59:59Z',
2931
}
3032

31-
const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
32-
expect(typeof input).toBe('string')
33-
expect(String(input)).toBe('https://cli.test/api/auth/cli/code')
34-
expect(init?.method).toBe('POST')
35-
expect(init?.headers).toEqual({ 'Content-Type': 'application/json' })
36-
expect(init?.body).toBe(JSON.stringify({ fingerprintId: 'finger-001' }))
37-
return new Response(JSON.stringify(responsePayload), { status: 200 })
33+
const loginCodeMock = mock(async (req: { fingerprintId: string }) => {
34+
expect(req.fingerprintId).toBe('finger-001')
35+
return {
36+
ok: true,
37+
status: 200,
38+
data: responsePayload,
39+
} as ApiResponse<LoginUrlResponse>
3840
})
3941

42+
const apiClient = createMockApiClient({ loginCode: loginCodeMock })
43+
4044
const result = await generateLoginUrl(
41-
{ fetch: fetchMock as any, logger },
45+
{ logger, apiClient },
4246
{ baseUrl: 'https://cli.test', fingerprintId: 'finger-001' },
4347
)
4448

4549
expect(result).toEqual(responsePayload)
46-
expect(fetchMock.mock.calls.length).toBe(1)
50+
expect(loginCodeMock.mock.calls.length).toBe(1)
4751
})
4852

4953
test('pollLoginStatus resolves with user after handling transient 401 responses', async () => {
5054
const logger = createLogger()
51-
const responses: Array<Response> = [
52-
new Response(null, { status: 401 }),
53-
new Response(null, { status: 401 }),
54-
new Response(
55-
JSON.stringify({
55+
const apiResponses: Array<ApiResponse<{ user?: unknown }>> = [
56+
{ ok: false, status: 401 },
57+
{ ok: false, status: 401 },
58+
{
59+
ok: true,
60+
status: 200,
61+
data: {
5662
user: {
5763
id: 'new-user-123',
5864
name: 'New User',
5965
email: 'new@codebuff.dev',
6066
authToken: 'token-123',
6167
},
62-
}),
63-
{ status: 200 },
64-
),
68+
},
69+
},
6570
]
6671
let callCount = 0
6772

68-
const fetchMock = mock(async (input: RequestInfo) => {
69-
const url = new URL(String(input))
70-
expect(url.searchParams.get('fingerprintId')).toBe('finger-abc')
71-
expect(url.searchParams.get('fingerprintHash')).toBe('hash-xyz')
72-
expect(url.searchParams.get('expiresAt')).toBe('2030-01-02T03:04:05Z')
73+
const loginStatusMock = mock(
74+
async (req: {
75+
fingerprintId: string
76+
fingerprintHash: string
77+
expiresAt: string
78+
}) => {
79+
expect(req.fingerprintId).toBe('finger-abc')
80+
expect(req.fingerprintHash).toBe('hash-xyz')
81+
expect(req.expiresAt).toBe('2030-01-02T03:04:05Z')
82+
83+
const response =
84+
apiResponses[callCount] ?? apiResponses[apiResponses.length - 1]
85+
callCount += 1
86+
return response
87+
},
88+
)
7389

74-
const response = responses[callCount] ?? responses[responses.length - 1]
75-
callCount += 1
76-
return response
77-
})
90+
const apiClient = createMockApiClient({ loginStatus: loginStatusMock })
7891

7992
const result = await pollLoginStatus(
8093
{
81-
fetch: fetchMock as any,
8294
sleep: async () => {},
8395
logger,
96+
apiClient,
8497
},
8598
{
8699
baseUrl: 'https://cli.test',
@@ -97,7 +110,7 @@ describe('First-Time Login Flow (helpers)', () => {
97110
expect(result.attempts).toBe(3)
98111
const user = result.user as { id?: unknown }
99112
expect(user?.id).toBe('new-user-123')
100-
expect(fetchMock.mock.calls.length).toBe(3)
113+
expect(loginStatusMock.mock.calls.length).toBe(3)
101114
})
102115

103116
test('pollLoginStatus times out when user never appears', async () => {
@@ -106,20 +119,22 @@ describe('First-Time Login Flow (helpers)', () => {
106119
const intervalMs = 5000
107120
const timeoutMs = 20000
108121

109-
const fetchMock = mock(async () => {
110-
return new Response(null, { status: 401 })
122+
const loginStatusMock = mock(async () => {
123+
return { ok: false, status: 401 } as ApiResponse<{ user?: unknown }>
111124
})
112125

126+
const apiClient = createMockApiClient({ loginStatus: loginStatusMock })
127+
113128
const sleep = async () => {
114129
nowTime += intervalMs
115130
}
116131

117132
const result = await pollLoginStatus(
118133
{
119-
fetch: fetchMock as any,
120134
sleep,
121135
logger,
122136
now: () => nowTime,
137+
apiClient,
123138
},
124139
{
125140
baseUrl: 'https://cli.test',
@@ -132,26 +147,26 @@ describe('First-Time Login Flow (helpers)', () => {
132147
)
133148

134149
expect(result.status).toBe('timeout')
135-
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
150+
expect(loginStatusMock.mock.calls.length).toBeGreaterThan(0)
136151
})
137152

138153
test('pollLoginStatus stops when caller aborts', async () => {
139154
const logger = createLogger()
140-
let attempts = 0
141-
const fetchMock = mock(async () => {
142-
attempts += 1
143-
return new Response(null, { status: 401 })
155+
const loginStatusMock = mock(async () => {
156+
return { ok: false, status: 401 } as ApiResponse<{ user?: unknown }>
144157
})
145158

159+
const apiClient = createMockApiClient({ loginStatus: loginStatusMock })
160+
146161
let shouldContinue = true
147162

148163
const resultPromise = pollLoginStatus(
149164
{
150-
fetch: fetchMock as any,
151165
sleep: async () => {
152166
shouldContinue = false
153167
},
154168
logger,
169+
apiClient,
155170
},
156171
{
157172
baseUrl: 'https://cli.test',
@@ -164,6 +179,6 @@ describe('First-Time Login Flow (helpers)', () => {
164179

165180
const result = await resultPromise
166181
expect(result.status).toBe('aborted')
167-
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
182+
expect(loginStatusMock.mock.calls.length).toBeGreaterThan(0)
168183
})
169184
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { mock } from 'bun:test'
2+
3+
import type { CodebuffApiClient, ApiResponse } from '../../utils/codebuff-api'
4+
5+
export interface MockApiClientOverrides {
6+
get?: ReturnType<typeof mock>
7+
post?: ReturnType<typeof mock>
8+
put?: ReturnType<typeof mock>
9+
patch?: ReturnType<typeof mock>
10+
delete?: ReturnType<typeof mock>
11+
request?: ReturnType<typeof mock>
12+
me?: ReturnType<typeof mock>
13+
usage?: ReturnType<typeof mock>
14+
loginCode?: ReturnType<typeof mock>
15+
loginStatus?: ReturnType<typeof mock>
16+
referral?: ReturnType<typeof mock>
17+
publish?: ReturnType<typeof mock>
18+
logout?: ReturnType<typeof mock>
19+
baseUrl?: string
20+
authToken?: string
21+
}
22+
23+
/**
24+
* Default OK response for mock API methods.
25+
* Returns { ok: true, status: 200 } without data, matching our ApiResponse type
26+
* where `data` is optional for responses without a body.
27+
*/
28+
const defaultOkResponse = () =>
29+
Promise.resolve({ ok: true as const, status: 200 })
30+
31+
/**
32+
* Creates a mock CodebuffApiClient with sensible defaults.
33+
* All methods return { ok: true, status: 200 } by default.
34+
* Pass overrides to customize specific methods.
35+
*/
36+
export const createMockApiClient = (
37+
overrides: MockApiClientOverrides = {},
38+
): CodebuffApiClient => ({
39+
get: (overrides.get ?? mock(defaultOkResponse)) as CodebuffApiClient['get'],
40+
post: (overrides.post ?? mock(defaultOkResponse)) as CodebuffApiClient['post'],
41+
put: (overrides.put ?? mock(defaultOkResponse)) as CodebuffApiClient['put'],
42+
patch: (overrides.patch ?? mock(defaultOkResponse)) as CodebuffApiClient['patch'],
43+
delete: (overrides.delete ?? mock(defaultOkResponse)) as CodebuffApiClient['delete'],
44+
request: (overrides.request ?? mock(defaultOkResponse)) as CodebuffApiClient['request'],
45+
me: (overrides.me ?? mock(defaultOkResponse)) as CodebuffApiClient['me'],
46+
usage: (overrides.usage ?? mock(defaultOkResponse)) as CodebuffApiClient['usage'],
47+
loginCode: (overrides.loginCode ?? mock(defaultOkResponse)) as CodebuffApiClient['loginCode'],
48+
loginStatus: (overrides.loginStatus ?? mock(defaultOkResponse)) as CodebuffApiClient['loginStatus'],
49+
referral: (overrides.referral ?? mock(defaultOkResponse)) as CodebuffApiClient['referral'],
50+
publish: (overrides.publish ?? mock(defaultOkResponse)) as CodebuffApiClient['publish'],
51+
logout: (overrides.logout ?? mock(defaultOkResponse)) as CodebuffApiClient['logout'],
52+
baseUrl: overrides.baseUrl ?? 'https://test.codebuff.com',
53+
authToken: overrides.authToken,
54+
})

cli/src/__tests__/integration/api-integration.test.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
21
import {
32
AuthenticationError,
43
NetworkError,
54
getUserInfoFromApiKey,
6-
WEBSITE_URL,
75
} from '@codebuff/sdk'
8-
import { userColumns } from '@codebuff/common/types/contracts/database'
6+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
97

108
import type { Logger } from '@codebuff/common/types/contracts/logger'
119

@@ -101,11 +99,10 @@ describe('API Integration', () => {
10199
const requestedUrl =
102100
request instanceof Request ? request.url : String(request)
103101

104-
const expectedQuery = new URLSearchParams({
105-
fields: userColumns.join(','),
106-
}).toString()
107-
108-
expect(requestedUrl).toBe(`${WEBSITE_URL}/api/v1/me?${expectedQuery}`)
102+
// Verify the URL starts with the expected base and endpoint
103+
expect(requestedUrl).toContain('/api/v1/me?')
104+
// Verify it contains the fields parameter
105+
expect(requestedUrl).toContain('fields=')
109106
})
110107

111108
test('should handle 200 OK responses from /api/v1/me correctly', async () => {

0 commit comments

Comments
 (0)