Skip to content

Commit ce95bad

Browse files
committed
feat: implement background job status handling with API integration
1 parent 5250a77 commit ce95bad

File tree

7 files changed

+726
-4
lines changed

7 files changed

+726
-4
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { renderHook, waitFor } from '@testing-library/react'
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { useBackgroundJobStatus } from './useBackgroundJobStatus'
5+
import { getTimestamp } from '@/lib/utils/date'
6+
7+
describe('useBackgroundJobStatus', () => {
8+
let mockFetch: ReturnType<typeof vi.fn>
9+
10+
const createWrapper = () => {
11+
const queryClient = new QueryClient({
12+
defaultOptions: {
13+
queries: {
14+
retry: false,
15+
refetchInterval: false, // Disable polling for simpler tests
16+
},
17+
},
18+
})
19+
return ({ children }: { children: React.ReactNode }) => (
20+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
21+
)
22+
}
23+
24+
beforeEach(() => {
25+
mockFetch = vi.fn()
26+
vi.stubGlobal('fetch', mockFetch)
27+
})
28+
29+
afterEach(() => {
30+
vi.unstubAllGlobals()
31+
})
32+
33+
describe('when job is running', () => {
34+
it('fetches job status and returns data', async () => {
35+
const job = {
36+
id: 'job-1',
37+
status: 'running' as const,
38+
createdAt: getTimestamp(),
39+
}
40+
41+
mockFetch.mockResolvedValue({
42+
ok: true,
43+
json: async () => ({ status: 'completed', response: 'Success!' }),
44+
})
45+
46+
const { result } = renderHook(() => useBackgroundJobStatus(job), {
47+
wrapper: createWrapper(),
48+
})
49+
50+
await waitFor(() => {
51+
expect(result.current.data).toEqual({
52+
status: 'completed',
53+
response: 'Success!',
54+
})
55+
})
56+
57+
expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', {
58+
method: 'POST',
59+
headers: { 'Content-Type': 'application/json' },
60+
body: JSON.stringify({ id: 'job-1' }),
61+
})
62+
})
63+
64+
it('handles different status responses', async () => {
65+
const job = {
66+
id: 'job-2',
67+
status: 'running' as const,
68+
createdAt: getTimestamp(),
69+
}
70+
71+
mockFetch.mockResolvedValue({
72+
ok: true,
73+
json: async () => ({
74+
status: 'failed',
75+
error: 'Something went wrong',
76+
}),
77+
})
78+
79+
const { result } = renderHook(() => useBackgroundJobStatus(job), {
80+
wrapper: createWrapper(),
81+
})
82+
83+
await waitFor(() => {
84+
expect(result.current.data?.status).toBe('failed')
85+
expect(result.current.data?.error).toBe('Something went wrong')
86+
})
87+
})
88+
})
89+
90+
describe('when job is completed', () => {
91+
it('does not fetch job status', () => {
92+
const job = {
93+
id: 'job-3',
94+
status: 'completed' as const,
95+
createdAt: getTimestamp(),
96+
}
97+
98+
const { result } = renderHook(() => useBackgroundJobStatus(job), {
99+
wrapper: createWrapper(),
100+
})
101+
102+
expect(result.current.isLoading).toBe(false)
103+
expect(result.current.data).toBeUndefined()
104+
expect(mockFetch).not.toHaveBeenCalled()
105+
})
106+
})
107+
108+
describe('when job is failed', () => {
109+
it('does not fetch job status', () => {
110+
const job = {
111+
id: 'job-4',
112+
status: 'failed' as const,
113+
createdAt: getTimestamp(),
114+
}
115+
116+
const { result } = renderHook(() => useBackgroundJobStatus(job), {
117+
wrapper: createWrapper(),
118+
})
119+
120+
expect(result.current.isLoading).toBe(false)
121+
expect(result.current.data).toBeUndefined()
122+
expect(mockFetch).not.toHaveBeenCalled()
123+
})
124+
})
125+
126+
describe('when job is undefined', () => {
127+
it('does not fetch and returns undefined', () => {
128+
const { result } = renderHook(() => useBackgroundJobStatus(undefined), {
129+
wrapper: createWrapper(),
130+
})
131+
132+
expect(result.current.isLoading).toBe(false)
133+
expect(result.current.data).toBeUndefined()
134+
expect(mockFetch).not.toHaveBeenCalled()
135+
})
136+
})
137+
138+
describe('error handling', () => {
139+
const createErrorWrapper = () => {
140+
const queryClient = new QueryClient({
141+
defaultOptions: {
142+
queries: {
143+
retry: false,
144+
refetchInterval: false,
145+
},
146+
},
147+
})
148+
return ({ children }: { children: React.ReactNode }) => (
149+
<QueryClientProvider client={queryClient}>
150+
{children}
151+
</QueryClientProvider>
152+
)
153+
}
154+
155+
it('handles HTTP 404 errors without retrying', async () => {
156+
const job = {
157+
id: 'job-5',
158+
status: 'running' as const,
159+
createdAt: getTimestamp(),
160+
}
161+
162+
mockFetch.mockResolvedValue({
163+
ok: false,
164+
status: 404,
165+
})
166+
167+
const { result } = renderHook(() => useBackgroundJobStatus(job), {
168+
wrapper: createErrorWrapper(),
169+
})
170+
171+
await waitFor(() => {
172+
expect(result.current.isError).toBe(true)
173+
})
174+
175+
expect(result.current.error).toBeInstanceOf(Error)
176+
expect(result.current.error?.message).toContain('404')
177+
// Should only be called once (no retries for 4xx errors)
178+
expect(mockFetch).toHaveBeenCalledTimes(1)
179+
})
180+
181+
it('calls fetch with correct parameters for error scenarios', async () => {
182+
const job = {
183+
id: 'job-6',
184+
status: 'running' as const,
185+
createdAt: getTimestamp(),
186+
}
187+
188+
mockFetch.mockRejectedValue(new Error('Network error'))
189+
190+
renderHook(() => useBackgroundJobStatus(job), {
191+
wrapper: createErrorWrapper(),
192+
})
193+
194+
await waitFor(() => {
195+
expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', {
196+
method: 'POST',
197+
headers: { 'Content-Type': 'application/json' },
198+
body: JSON.stringify({ id: 'job-6' }),
199+
})
200+
})
201+
})
202+
203+
it('calls fetch for HTTP error responses', async () => {
204+
const job = {
205+
id: 'job-7',
206+
status: 'running' as const,
207+
createdAt: getTimestamp(),
208+
}
209+
210+
mockFetch.mockResolvedValue({
211+
ok: false,
212+
status: 500,
213+
})
214+
215+
renderHook(() => useBackgroundJobStatus(job), {
216+
wrapper: createErrorWrapper(),
217+
})
218+
219+
await waitFor(() => {
220+
expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', {
221+
method: 'POST',
222+
headers: { 'Content-Type': 'application/json' },
223+
body: JSON.stringify({ id: 'job-7' }),
224+
})
225+
})
226+
})
227+
})
228+
})

src/hooks/useBackgroundJobStatus.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import type { BackgroundJob } from '../lib/schemas'
3+
4+
interface BackgroundJobStatusResponse {
5+
status: 'running' | 'completed' | 'failed'
6+
response?: string
7+
error?: string
8+
completedAt?: string
9+
}
10+
11+
class HttpError extends Error {
12+
constructor(
13+
public status: number,
14+
message: string,
15+
) {
16+
super(message)
17+
this.name = 'HttpError'
18+
}
19+
}
20+
21+
const fetchJobStatus = async (
22+
id: string,
23+
): Promise<BackgroundJobStatusResponse> => {
24+
const response = await fetch('/api/background-jobs', {
25+
method: 'POST',
26+
headers: { 'Content-Type': 'application/json' },
27+
body: JSON.stringify({ id }),
28+
})
29+
30+
if (!response.ok) {
31+
throw new HttpError(
32+
response.status,
33+
`Failed to fetch job status: ${response.status}`,
34+
)
35+
}
36+
37+
return response.json()
38+
}
39+
40+
export const useBackgroundJobStatus = (
41+
job: BackgroundJob | undefined,
42+
refetchInterval: number = 2000,
43+
) => {
44+
return useQuery({
45+
queryKey: ['backgroundJobStatus', job?.id],
46+
queryFn: () => {
47+
if (!job?.id) {
48+
throw new Error('No request ID available')
49+
}
50+
return fetchJobStatus(job.id)
51+
},
52+
enabled: !!job?.id && job.status === 'running',
53+
refetchInterval,
54+
refetchIntervalInBackground: true,
55+
retry: (failureCount, error) => {
56+
// Don't retry on 4xx errors (client errors)
57+
if (
58+
error instanceof HttpError &&
59+
error.status >= 400 &&
60+
error.status < 500
61+
) {
62+
return false
63+
}
64+
// Retry network errors and 5xx errors up to 3 times
65+
return failureCount < 3
66+
},
67+
retryDelay: 1000,
68+
})
69+
}

0 commit comments

Comments
 (0)