Skip to content

Commit 7978975

Browse files
committed
feat: implement useBackgroundJobs hook for job management with local storage
1 parent 91f44b3 commit 7978975

File tree

2 files changed

+448
-0
lines changed

2 files changed

+448
-0
lines changed

src/hooks/useBackgroundJobs.test.tsx

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import { renderHook, act } from '@testing-library/react'
2+
import { describe, it, expect, beforeEach } from 'vitest'
3+
import { useBackgroundJobs } from './useBackgroundJobs'
4+
import type { BackgroundJob } from '../lib/schemas'
5+
6+
describe('useBackgroundJobs', () => {
7+
const STORAGE_KEY = 'background-jobs'
8+
9+
beforeEach(() => {
10+
localStorage.clear()
11+
})
12+
13+
const createMockJob = (
14+
overrides: Partial<BackgroundJob> = {},
15+
): BackgroundJob => ({
16+
id: 'test-job-1',
17+
status: 'running',
18+
createdAt: '2025-01-01T00:00:00Z',
19+
title: 'Test Job',
20+
...overrides,
21+
})
22+
23+
describe('initialization and persistence', () => {
24+
it('should start with empty jobs when no data in localStorage', () => {
25+
const { result } = renderHook(() => useBackgroundJobs())
26+
27+
expect(result.current.jobs).toEqual([])
28+
expect(result.current.jobsMap).toEqual({})
29+
})
30+
31+
it('should load existing jobs from localStorage', () => {
32+
const job1 = createMockJob({ id: 'job1' })
33+
const job2 = createMockJob({ id: 'job2', status: 'completed' })
34+
const storedData = { job1, job2 }
35+
36+
localStorage.setItem(STORAGE_KEY, JSON.stringify(storedData))
37+
38+
const { result } = renderHook(() => useBackgroundJobs())
39+
40+
expect(result.current.jobs).toHaveLength(2)
41+
expect(result.current.jobs).toContainEqual(job1)
42+
expect(result.current.jobs).toContainEqual(job2)
43+
expect(result.current.jobsMap).toEqual(storedData)
44+
})
45+
46+
it('should persist jobs across hook instances', () => {
47+
const job = createMockJob()
48+
49+
// First hook instance adds a job
50+
const { result: firstResult, unmount } = renderHook(() =>
51+
useBackgroundJobs(),
52+
)
53+
act(() => {
54+
firstResult.current.addJob(job)
55+
})
56+
unmount()
57+
58+
// Second hook instance should load the persisted job
59+
const { result: secondResult } = renderHook(() => useBackgroundJobs())
60+
expect(secondResult.current.jobs).toContainEqual(job)
61+
})
62+
})
63+
64+
describe('job management', () => {
65+
it('should add a job and make it available', () => {
66+
const { result } = renderHook(() => useBackgroundJobs())
67+
const newJob = createMockJob()
68+
69+
act(() => {
70+
result.current.addJob(newJob)
71+
})
72+
73+
expect(result.current.jobs).toContainEqual(newJob)
74+
expect(result.current.jobsMap[newJob.id]).toEqual(newJob)
75+
expect(result.current.getJobById(newJob.id)).toEqual(newJob)
76+
})
77+
78+
it('should add multiple jobs', () => {
79+
const { result } = renderHook(() => useBackgroundJobs())
80+
const job1 = createMockJob({ id: 'job1', title: 'Job 1' })
81+
const job2 = createMockJob({ id: 'job2', title: 'Job 2' })
82+
83+
expect(result.current.jobs).toHaveLength(0)
84+
85+
act(() => {
86+
result.current.addJob(job1)
87+
})
88+
89+
expect(result.current.jobs).toHaveLength(1)
90+
expect(result.current.jobs).toContainEqual(job1)
91+
92+
act(() => {
93+
result.current.addJob(job2)
94+
})
95+
96+
expect(result.current.jobs).toHaveLength(2)
97+
expect(result.current.jobs).toContainEqual(job1)
98+
expect(result.current.jobs).toContainEqual(job2)
99+
})
100+
101+
it('should overwrite job with same id', () => {
102+
const { result } = renderHook(() => useBackgroundJobs())
103+
const originalJob = createMockJob({ status: 'running' })
104+
const updatedJob = createMockJob({ status: 'completed' })
105+
106+
act(() => {
107+
result.current.addJob(originalJob)
108+
result.current.addJob(updatedJob)
109+
})
110+
111+
expect(result.current.jobs).toHaveLength(1)
112+
expect(result.current.getJobById(updatedJob.id)).toEqual(updatedJob)
113+
expect(result.current.getJobById(updatedJob.id)?.status).toBe('completed')
114+
})
115+
116+
it('should remove a job by id', () => {
117+
const { result } = renderHook(() => useBackgroundJobs())
118+
const job1 = createMockJob({ id: 'job1' })
119+
const job2 = createMockJob({ id: 'job2' })
120+
121+
act(() => {
122+
result.current.addJob(job1)
123+
result.current.addJob(job2)
124+
})
125+
126+
act(() => {
127+
result.current.removeJob('job1')
128+
})
129+
130+
expect(result.current.jobs).toHaveLength(1)
131+
expect(result.current.jobs).toContainEqual(job2)
132+
expect(result.current.getJobById('job1')).toBeUndefined()
133+
expect(result.current.getJobById('job2')).toEqual(job2)
134+
})
135+
136+
it('should handle removing non-existent job gracefully', () => {
137+
const { result } = renderHook(() => useBackgroundJobs())
138+
const job = createMockJob()
139+
140+
act(() => {
141+
result.current.addJob(job)
142+
})
143+
144+
act(() => {
145+
result.current.removeJob('non-existent')
146+
})
147+
148+
expect(result.current.jobs).toHaveLength(1)
149+
expect(result.current.jobs).toContainEqual(job)
150+
})
151+
152+
it('should update existing job with partial data', () => {
153+
const { result } = renderHook(() => useBackgroundJobs())
154+
const job = createMockJob({ status: 'running' })
155+
156+
act(() => {
157+
result.current.addJob(job)
158+
})
159+
160+
act(() => {
161+
result.current.updateJob(job.id, {
162+
status: 'completed',
163+
completedAt: '2025-01-01T01:00:00Z',
164+
})
165+
})
166+
167+
const updatedJob = result.current.getJobById(job.id)
168+
expect(updatedJob?.status).toBe('completed')
169+
expect(updatedJob?.completedAt).toBe('2025-01-01T01:00:00Z')
170+
expect(updatedJob?.title).toBe(job.title) // Should preserve other fields
171+
})
172+
173+
it('should handle updating non-existent job gracefully', () => {
174+
const { result } = renderHook(() => useBackgroundJobs())
175+
const existingJob = createMockJob({ id: 'existing' })
176+
177+
act(() => {
178+
result.current.addJob(existingJob)
179+
})
180+
181+
act(() => {
182+
result.current.updateJob('non-existent', { status: 'completed' })
183+
})
184+
185+
// Should not affect existing jobs and should not create undefined entries
186+
expect(result.current.jobs).toHaveLength(1)
187+
expect(result.current.jobs).toContainEqual(existingJob)
188+
expect(result.current.getJobById('non-existent')).toBeUndefined()
189+
})
190+
191+
it('should clear all jobs', () => {
192+
const { result } = renderHook(() => useBackgroundJobs())
193+
const job1 = createMockJob({ id: 'job1' })
194+
const job2 = createMockJob({ id: 'job2' })
195+
196+
act(() => {
197+
result.current.addJob(job1)
198+
result.current.addJob(job2)
199+
})
200+
201+
act(() => {
202+
result.current.clearAllJobs()
203+
})
204+
205+
expect(result.current.jobs).toHaveLength(0)
206+
expect(result.current.jobsMap).toEqual({})
207+
})
208+
})
209+
210+
describe('real-world scenarios', () => {
211+
it('should handle complete job lifecycle with persistence', () => {
212+
const { result } = renderHook(() => useBackgroundJobs())
213+
const job = createMockJob()
214+
215+
act(() => {
216+
result.current.addJob(job)
217+
})
218+
219+
act(() => {
220+
result.current.updateJob(job.id, {
221+
status: 'completed',
222+
response: 'Job completed successfully',
223+
completedAt: '2025-01-01T01:00:00Z',
224+
})
225+
})
226+
227+
const finalJob = result.current.getJobById(job.id)
228+
expect(finalJob?.status).toBe('completed')
229+
expect(finalJob?.response).toBe('Job completed successfully')
230+
expect(finalJob?.completedAt).toBe('2025-01-01T01:00:00Z')
231+
232+
const storedData = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
233+
expect(storedData[job.id]).toEqual(finalJob)
234+
})
235+
236+
it('should handle concurrent job operations', () => {
237+
const { result } = renderHook(() => useBackgroundJobs())
238+
const runningJob = createMockJob({
239+
id: 'concurrent-running-xyz',
240+
status: 'running',
241+
title: 'Running Job',
242+
})
243+
const completedJob = createMockJob({
244+
id: 'concurrent-completed-abc',
245+
status: 'completed',
246+
title: 'Completed Job',
247+
})
248+
const failedJob = createMockJob({
249+
id: 'concurrent-failed-def',
250+
status: 'failed',
251+
title: 'Failed Job',
252+
})
253+
254+
expect(result.current.jobs).toHaveLength(0)
255+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
256+
257+
act(() => {
258+
result.current.addJob(runningJob)
259+
})
260+
expect(result.current.jobs).toHaveLength(1)
261+
262+
act(() => {
263+
result.current.addJob(completedJob)
264+
})
265+
expect(result.current.jobs).toHaveLength(2)
266+
267+
act(() => {
268+
result.current.addJob(failedJob)
269+
})
270+
271+
expect(result.current.jobs).toHaveLength(3)
272+
273+
act(() => {
274+
result.current.removeJob('concurrent-completed-abc')
275+
})
276+
277+
act(() => {
278+
result.current.updateJob('concurrent-running-xyz', {
279+
status: 'completed',
280+
})
281+
})
282+
283+
expect(result.current.jobs).toHaveLength(2)
284+
expect(result.current.getJobById('concurrent-running-xyz')?.status).toBe(
285+
'completed',
286+
)
287+
expect(
288+
result.current.getJobById('concurrent-completed-abc'),
289+
).toBeUndefined()
290+
expect(result.current.getJobById('concurrent-failed-def')).toEqual(
291+
failedJob,
292+
)
293+
})
294+
295+
it('should maintain data integrity across browser sessions', () => {
296+
const job1 = createMockJob({
297+
id: 'browser-session-alpha',
298+
title: 'Session 1 Job',
299+
})
300+
const job2 = createMockJob({
301+
id: 'browser-session-beta',
302+
title: 'Session 2 Job',
303+
})
304+
305+
let firstSessionResult = renderHook(() => useBackgroundJobs())
306+
307+
expect(firstSessionResult.result.current.jobs).toHaveLength(0)
308+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
309+
310+
act(() => {
311+
firstSessionResult.result.current.addJob(job1)
312+
})
313+
expect(firstSessionResult.result.current.jobs).toHaveLength(1)
314+
315+
act(() => {
316+
firstSessionResult.result.current.addJob(job2)
317+
})
318+
319+
expect(firstSessionResult.result.current.jobs).toHaveLength(2)
320+
firstSessionResult.unmount()
321+
322+
let secondSessionResult = renderHook(() => useBackgroundJobs())
323+
expect(secondSessionResult.result.current.jobs).toHaveLength(2)
324+
expect(
325+
secondSessionResult.result.current.getJobById('browser-session-alpha'),
326+
).toEqual(job1)
327+
expect(
328+
secondSessionResult.result.current.getJobById('browser-session-beta'),
329+
).toEqual(job2)
330+
331+
act(() => {
332+
secondSessionResult.result.current.updateJob('browser-session-alpha', {
333+
status: 'completed',
334+
})
335+
})
336+
337+
expect(
338+
secondSessionResult.result.current.getJobById('browser-session-alpha')
339+
?.status,
340+
).toBe('completed')
341+
342+
act(() => {
343+
secondSessionResult.result.current.removeJob('browser-session-beta')
344+
})
345+
346+
expect(secondSessionResult.result.current.jobs).toHaveLength(1)
347+
expect(
348+
secondSessionResult.result.current.getJobById('browser-session-beta'),
349+
).toBeUndefined()
350+
351+
secondSessionResult.unmount()
352+
353+
let thirdSessionResult = renderHook(() => useBackgroundJobs())
354+
expect(thirdSessionResult.result.current.jobs).toHaveLength(1)
355+
356+
const persistedJob = thirdSessionResult.result.current.getJobById(
357+
'browser-session-alpha',
358+
)
359+
expect(persistedJob).toBeDefined()
360+
expect(persistedJob?.status).toBe('completed')
361+
expect(persistedJob?.title).toBe('Session 1 Job') // Should preserve other fields
362+
expect(
363+
thirdSessionResult.result.current.getJobById('browser-session-beta'),
364+
).toBeUndefined()
365+
thirdSessionResult.unmount()
366+
})
367+
})
368+
})

0 commit comments

Comments
 (0)