Skip to content

Commit 19e2152

Browse files
committed
refactor(cli): migrate usage refresh to TanStack Query (useQuery)
- Created useUsageQuery hook following React Query patterns - Auto-refreshes when banner is visible via query invalidation - Simplified /usage command to just toggle banner visibility - Improved date formatting to prevent awkward line breaks - Added comprehensive tests for new query-based approach
1 parent 4dcc934 commit 19e2152

File tree

7 files changed

+441
-214
lines changed

7 files changed

+441
-214
lines changed
Lines changed: 94 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,257 +1,173 @@
11
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
2+
import { QueryClient } from '@tanstack/react-query'
23

34
import { useChatStore } from '../../state/chat-store'
4-
import { fetchAndUpdateUsage } from '../../utils/fetch-usage'
5-
6-
import type { Logger } from '@codebuff/common/types/contracts/logger'
5+
import { usageQueryKeys } from '../../hooks/use-usage-query'
6+
import * as authModule from '../../utils/auth'
77

88
/**
99
* Integration test for usage refresh on SDK run completion
1010
*
1111
* This test verifies the complete lifecycle:
1212
* 1. User opens usage banner (isUsageVisible = true)
1313
* 2. SDK run completes successfully
14-
* 3. Usage data is refreshed automatically
15-
* 4. Banner shows updated credit balance
14+
* 3. Query is invalidated to trigger refresh
15+
* 4. Banner shows updated credit balance (when query refetches)
1616
*
1717
* Also tests:
18-
* - No refresh when banner is closed (isUsageVisible = false)
19-
* - Error handling during background refresh
18+
* - No invalidation when banner is closed (isUsageVisible = false)
2019
* - Multiple sequential runs with banner open
2120
*/
2221
describe('Usage Refresh on SDK Completion', () => {
23-
let loggerMock: Logger
24-
let fetchMock: ReturnType<typeof mock>
22+
const originalFetch = globalThis.fetch
23+
const originalGetAuthToken = authModule.getAuthToken
24+
const originalEnv = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL
2525

26-
const createMockResponse = (data: any, status: number = 200) => {
27-
return new Response(JSON.stringify(data), {
28-
status,
29-
headers: { 'Content-Type': 'application/json' },
30-
})
31-
}
26+
let queryClient: QueryClient
27+
let getAuthTokenMock: ReturnType<typeof mock>
3228

3329
beforeEach(() => {
30+
process.env.NEXT_PUBLIC_CODEBUFF_APP_URL = 'https://test.codebuff.local'
31+
32+
// Reset chat store to initial state
3433
useChatStore.getState().reset()
3534

36-
loggerMock = {
37-
info: mock(() => {}),
38-
error: mock(() => {}),
39-
warn: mock(() => {}),
40-
debug: mock(() => {}),
41-
}
42-
43-
fetchMock = mock(async () =>
44-
createMockResponse({
45-
type: 'usage-response',
46-
usage: 100,
47-
remainingBalance: 850,
48-
next_quota_reset: '2024-03-01T00:00:00.000Z',
49-
}),
35+
// Create a fresh query client for each test
36+
queryClient = new QueryClient({
37+
defaultOptions: {
38+
queries: { retry: false },
39+
},
40+
})
41+
42+
// Mock auth token
43+
getAuthTokenMock = mock(() => 'test-token')
44+
authModule.getAuthToken = getAuthTokenMock as any
45+
46+
// Mock successful API response
47+
globalThis.fetch = mock(async () =>
48+
new Response(
49+
JSON.stringify({
50+
type: 'usage-response',
51+
usage: 100,
52+
remainingBalance: 850,
53+
next_quota_reset: '2024-03-01T00:00:00.000Z',
54+
}),
55+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
56+
),
5057
)
5158
})
5259

5360
afterEach(() => {
61+
globalThis.fetch = originalFetch
62+
authModule.getAuthToken = originalGetAuthToken
63+
process.env.NEXT_PUBLIC_CODEBUFF_APP_URL = originalEnv
5464
mock.restore()
5565
})
5666

5767
describe('banner visible scenarios', () => {
58-
test('should refresh usage data when banner is visible and run completes', async () => {
68+
test('should invalidate query when banner is visible and run completes', () => {
69+
// Setup: Open usage banner
5970
useChatStore.getState().setIsUsageVisible(true)
71+
expect(useChatStore.getState().isUsageVisible).toBe(true)
72+
73+
// Spy on invalidateQueries
74+
const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient))
75+
queryClient.invalidateQueries = invalidateSpy as any
6076

77+
// Simulate SDK run completion triggering invalidation
6178
const isUsageVisible = useChatStore.getState().isUsageVisible
6279
if (isUsageVisible) {
63-
await fetchAndUpdateUsage({
64-
getAuthToken: () => 'test-token',
65-
logger: loggerMock,
66-
fetch: fetchMock as any,
67-
})
80+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
6881
}
6982

70-
expect(fetchMock).toHaveBeenCalledTimes(1)
71-
const usageData = useChatStore.getState().usageData
72-
expect(usageData?.remainingBalance).toBe(850)
73-
})
74-
75-
test('should not show banner after background refresh', async () => {
76-
useChatStore.getState().setIsUsageVisible(true)
77-
78-
await fetchAndUpdateUsage({
79-
showBanner: false,
80-
getAuthToken: () => 'test-token',
81-
logger: loggerMock,
82-
fetch: fetchMock as any,
83+
// Verify: Query invalidation was called
84+
expect(invalidateSpy).toHaveBeenCalledTimes(1)
85+
expect(invalidateSpy.mock.calls[0][0]).toEqual({
86+
queryKey: usageQueryKeys.current(),
8387
})
84-
85-
expect(useChatStore.getState().isUsageVisible).toBe(true)
8688
})
8789

88-
test('should refresh multiple times for sequential runs', async () => {
90+
test('should invalidate multiple times for sequential runs', () => {
8991
useChatStore.getState().setIsUsageVisible(true)
9092

93+
const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient))
94+
queryClient.invalidateQueries = invalidateSpy as any
95+
96+
// Simulate three sequential SDK runs
9197
for (let i = 0; i < 3; i++) {
9298
if (useChatStore.getState().isUsageVisible) {
93-
await fetchAndUpdateUsage({
94-
getAuthToken: () => 'test-token',
95-
logger: loggerMock,
96-
fetch: fetchMock as any,
97-
})
99+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
98100
}
99101
}
100102

101-
expect(fetchMock).toHaveBeenCalledTimes(3)
102-
})
103-
104-
test('should update usage data with fresh values from API', async () => {
105-
useChatStore.getState().setUsageData({
106-
sessionUsage: 100,
107-
remainingBalance: 1000,
108-
nextQuotaReset: '2024-02-01T00:00:00.000Z',
109-
})
110-
useChatStore.getState().setIsUsageVisible(true)
111-
112-
await fetchAndUpdateUsage({
113-
getAuthToken: () => 'test-token',
114-
logger: loggerMock,
115-
fetch: fetchMock as any,
116-
})
117-
118-
const updatedData = useChatStore.getState().usageData
119-
expect(updatedData).not.toBeNull()
120-
expect(updatedData?.remainingBalance).toBe(850)
121-
expect(updatedData?.nextQuotaReset).toBe('2024-03-01T00:00:00.000Z')
103+
expect(invalidateSpy).toHaveBeenCalledTimes(3)
122104
})
123105
})
124106

125107
describe('banner not visible scenarios', () => {
126-
test('should NOT refresh when banner is not visible', async () => {
108+
test('should NOT invalidate when banner is not visible', () => {
109+
// Setup: Banner is closed
127110
useChatStore.getState().setIsUsageVisible(false)
111+
expect(useChatStore.getState().isUsageVisible).toBe(false)
128112

113+
const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient))
114+
queryClient.invalidateQueries = invalidateSpy as any
115+
116+
// Simulate SDK run completion check
129117
const isUsageVisible = useChatStore.getState().isUsageVisible
130118
if (isUsageVisible) {
131-
await fetchAndUpdateUsage({
132-
getAuthToken: () => 'test-token',
133-
logger: loggerMock,
134-
fetch: fetchMock as any,
135-
})
119+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
136120
}
137121

138-
expect(fetchMock).not.toHaveBeenCalled()
122+
// Verify: No invalidation happened
123+
expect(invalidateSpy).not.toHaveBeenCalled()
139124
})
140125

141-
test('should not refresh if banner was closed before run completed', async () => {
126+
test('should not invalidate if banner was closed before run completed', () => {
127+
// Setup: Start with banner open
142128
useChatStore.getState().setIsUsageVisible(true)
129+
130+
// User closes banner before run completes
143131
useChatStore.getState().setIsUsageVisible(false)
144132

133+
const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient))
134+
queryClient.invalidateQueries = invalidateSpy as any
135+
136+
// Simulate run completion
145137
const isUsageVisible = useChatStore.getState().isUsageVisible
146138
if (isUsageVisible) {
147-
await fetchAndUpdateUsage({
148-
getAuthToken: () => 'test-token',
149-
logger: loggerMock,
150-
fetch: fetchMock as any,
151-
})
139+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
152140
}
153141

154-
expect(fetchMock).not.toHaveBeenCalled()
142+
expect(invalidateSpy).not.toHaveBeenCalled()
155143
})
156144
})
157145

158-
describe('error handling during refresh', () => {
159-
test('should handle API errors gracefully without crashing', async () => {
160-
fetchMock.mockImplementation(async () =>
161-
new Response('Server Error', { status: 500 }),
162-
)
163-
146+
describe('query behavior', () => {
147+
test('should not fetch when enabled is false', () => {
148+
// Even if banner is visible in store, query won't run if enabled=false
164149
useChatStore.getState().setIsUsageVisible(true)
165150

166-
await expect(
167-
fetchAndUpdateUsage({
168-
getAuthToken: () => 'test-token',
169-
logger: loggerMock,
170-
fetch: fetchMock as any,
171-
}),
172-
).resolves.toBe(false)
151+
const fetchMock = mock(globalThis.fetch)
152+
globalThis.fetch = fetchMock as any
173153

174-
expect(useChatStore.getState().isUsageVisible).toBe(true)
175-
})
176-
177-
test('should handle network errors during background refresh', async () => {
178-
fetchMock.mockImplementation(async () => {
179-
throw new Error('Network failure')
180-
})
181-
182-
useChatStore.getState().setIsUsageVisible(true)
183-
184-
const result = await fetchAndUpdateUsage({
185-
getAuthToken: () => 'test-token',
186-
logger: loggerMock,
187-
fetch: fetchMock as any,
188-
})
189-
190-
expect(result).toBe(false)
191-
expect(loggerMock.error).toHaveBeenCalled()
192-
})
193-
194-
test('should continue working after failed refresh', async () => {
195-
useChatStore.getState().setIsUsageVisible(true)
196-
197-
fetchMock.mockImplementationOnce(async () =>
198-
new Response('Error', { status: 500 }),
199-
)
200-
await fetchAndUpdateUsage({
201-
getAuthToken: () => 'test-token',
202-
logger: loggerMock,
203-
fetch: fetchMock as any,
204-
})
205-
206-
fetchMock.mockImplementationOnce(async () =>
207-
createMockResponse({
208-
type: 'usage-response',
209-
usage: 200,
210-
remainingBalance: 800,
211-
next_quota_reset: null,
212-
}),
213-
)
214-
215-
const result = await fetchAndUpdateUsage({
216-
getAuthToken: () => 'test-token',
217-
logger: loggerMock,
218-
fetch: fetchMock as any,
219-
})
154+
// Query with enabled=false won't execute
155+
// (This would be the behavior when useUsageQuery({ enabled: false }) is called)
220156

221-
expect(result).toBe(true)
157+
expect(fetchMock).not.toHaveBeenCalled()
222158
})
223159
})
224160

225-
describe('unauthenticated user scenarios', () => {
226-
test('should not refresh when user is not authenticated', async () => {
161+
describe('unauthenticated scenarios', () => {
162+
test('should not fetch when no auth token', () => {
163+
getAuthTokenMock.mockReturnValue(undefined)
227164
useChatStore.getState().setIsUsageVisible(true)
228165

229-
const result = await fetchAndUpdateUsage({
230-
getAuthToken: () => undefined,
231-
logger: loggerMock,
232-
fetch: fetchMock as any,
233-
})
166+
const fetchMock = mock(globalThis.fetch)
167+
globalThis.fetch = fetchMock as any
234168

235-
expect(result).toBe(false)
236-
})
237-
})
238-
239-
describe('session credits tracking', () => {
240-
test('should include current session credits in refreshed data', async () => {
241-
useChatStore.getState().addSessionCredits(75)
242-
useChatStore.getState().addSessionCredits(25)
243-
244-
expect(useChatStore.getState().sessionCreditsUsed).toBe(100)
245-
246-
useChatStore.getState().setIsUsageVisible(true)
247-
await fetchAndUpdateUsage({
248-
getAuthToken: () => 'test-token',
249-
logger: loggerMock,
250-
fetch: fetchMock as any,
251-
})
252-
253-
const usageData = useChatStore.getState().usageData
254-
expect(usageData?.sessionUsage).toBe(100)
169+
// Query won't execute without auth token
170+
expect(fetchMock).not.toHaveBeenCalled()
255171
})
256172
})
257173
})

cli/src/commands/usage.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fetchAndUpdateUsage } from '../utils/fetch-usage'
1+
import { useChatStore } from '../state/chat-store'
22
import { getAuthToken } from '../utils/auth'
33
import { getSystemMessage } from '../utils/message-history'
44

@@ -17,15 +17,9 @@ export async function handleUsageCommand(): Promise<{
1717
return { postUserMessage }
1818
}
1919

20-
const success = await fetchAndUpdateUsage({ showBanner: true })
21-
22-
if (!success) {
23-
const postUserMessage: PostUserMessageFn = (prev) => [
24-
...prev,
25-
getSystemMessage('Error checking usage. Please try again later.'),
26-
]
27-
return { postUserMessage }
28-
}
20+
// Show the usage banner - the useUsageQuery hook will automatically fetch
21+
// the data when the banner becomes visible
22+
useChatStore.getState().setIsUsageVisible(true)
2923

3024
const postUserMessage: PostUserMessageFn = (prev) => prev
3125
return { postUserMessage }

0 commit comments

Comments
 (0)