|
1 | 1 | import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' |
| 2 | +import { QueryClient } from '@tanstack/react-query' |
2 | 3 |
|
3 | 4 | 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' |
7 | 7 |
|
8 | 8 | /** |
9 | 9 | * Integration test for usage refresh on SDK run completion |
10 | 10 | * |
11 | 11 | * This test verifies the complete lifecycle: |
12 | 12 | * 1. User opens usage banner (isUsageVisible = true) |
13 | 13 | * 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) |
16 | 16 | * |
17 | 17 | * 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) |
20 | 19 | * - Multiple sequential runs with banner open |
21 | 20 | */ |
22 | 21 | 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 |
25 | 25 |
|
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> |
32 | 28 |
|
33 | 29 | beforeEach(() => { |
| 30 | + process.env.NEXT_PUBLIC_CODEBUFF_APP_URL = 'https://test.codebuff.local' |
| 31 | + |
| 32 | + // Reset chat store to initial state |
34 | 33 | useChatStore.getState().reset() |
35 | 34 |
|
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 | + ), |
50 | 57 | ) |
51 | 58 | }) |
52 | 59 |
|
53 | 60 | afterEach(() => { |
| 61 | + globalThis.fetch = originalFetch |
| 62 | + authModule.getAuthToken = originalGetAuthToken |
| 63 | + process.env.NEXT_PUBLIC_CODEBUFF_APP_URL = originalEnv |
54 | 64 | mock.restore() |
55 | 65 | }) |
56 | 66 |
|
57 | 67 | 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 |
59 | 70 | 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 |
60 | 76 |
|
| 77 | + // Simulate SDK run completion triggering invalidation |
61 | 78 | const isUsageVisible = useChatStore.getState().isUsageVisible |
62 | 79 | if (isUsageVisible) { |
63 | | - await fetchAndUpdateUsage({ |
64 | | - getAuthToken: () => 'test-token', |
65 | | - logger: loggerMock, |
66 | | - fetch: fetchMock as any, |
67 | | - }) |
| 80 | + queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() }) |
68 | 81 | } |
69 | 82 |
|
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(), |
83 | 87 | }) |
84 | | - |
85 | | - expect(useChatStore.getState().isUsageVisible).toBe(true) |
86 | 88 | }) |
87 | 89 |
|
88 | | - test('should refresh multiple times for sequential runs', async () => { |
| 90 | + test('should invalidate multiple times for sequential runs', () => { |
89 | 91 | useChatStore.getState().setIsUsageVisible(true) |
90 | 92 |
|
| 93 | + const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient)) |
| 94 | + queryClient.invalidateQueries = invalidateSpy as any |
| 95 | + |
| 96 | + // Simulate three sequential SDK runs |
91 | 97 | for (let i = 0; i < 3; i++) { |
92 | 98 | 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() }) |
98 | 100 | } |
99 | 101 | } |
100 | 102 |
|
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) |
122 | 104 | }) |
123 | 105 | }) |
124 | 106 |
|
125 | 107 | 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 |
127 | 110 | useChatStore.getState().setIsUsageVisible(false) |
| 111 | + expect(useChatStore.getState().isUsageVisible).toBe(false) |
128 | 112 |
|
| 113 | + const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient)) |
| 114 | + queryClient.invalidateQueries = invalidateSpy as any |
| 115 | + |
| 116 | + // Simulate SDK run completion check |
129 | 117 | const isUsageVisible = useChatStore.getState().isUsageVisible |
130 | 118 | if (isUsageVisible) { |
131 | | - await fetchAndUpdateUsage({ |
132 | | - getAuthToken: () => 'test-token', |
133 | | - logger: loggerMock, |
134 | | - fetch: fetchMock as any, |
135 | | - }) |
| 119 | + queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() }) |
136 | 120 | } |
137 | 121 |
|
138 | | - expect(fetchMock).not.toHaveBeenCalled() |
| 122 | + // Verify: No invalidation happened |
| 123 | + expect(invalidateSpy).not.toHaveBeenCalled() |
139 | 124 | }) |
140 | 125 |
|
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 |
142 | 128 | useChatStore.getState().setIsUsageVisible(true) |
| 129 | + |
| 130 | + // User closes banner before run completes |
143 | 131 | useChatStore.getState().setIsUsageVisible(false) |
144 | 132 |
|
| 133 | + const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient)) |
| 134 | + queryClient.invalidateQueries = invalidateSpy as any |
| 135 | + |
| 136 | + // Simulate run completion |
145 | 137 | const isUsageVisible = useChatStore.getState().isUsageVisible |
146 | 138 | if (isUsageVisible) { |
147 | | - await fetchAndUpdateUsage({ |
148 | | - getAuthToken: () => 'test-token', |
149 | | - logger: loggerMock, |
150 | | - fetch: fetchMock as any, |
151 | | - }) |
| 139 | + queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() }) |
152 | 140 | } |
153 | 141 |
|
154 | | - expect(fetchMock).not.toHaveBeenCalled() |
| 142 | + expect(invalidateSpy).not.toHaveBeenCalled() |
155 | 143 | }) |
156 | 144 | }) |
157 | 145 |
|
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 |
164 | 149 | useChatStore.getState().setIsUsageVisible(true) |
165 | 150 |
|
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 |
173 | 153 |
|
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) |
220 | 156 |
|
221 | | - expect(result).toBe(true) |
| 157 | + expect(fetchMock).not.toHaveBeenCalled() |
222 | 158 | }) |
223 | 159 | }) |
224 | 160 |
|
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) |
227 | 164 | useChatStore.getState().setIsUsageVisible(true) |
228 | 165 |
|
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 |
234 | 168 |
|
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() |
255 | 171 | }) |
256 | 172 | }) |
257 | 173 | }) |
0 commit comments