import { renderHook, act } from '@testing-library/react-hooks'
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
let useImageUrl
describe('useImageUrl', () => {
let abortSpy
let OriginalAbortController
let originalCreateObjectURL
let originalRevokeObjectURL
let originalFetch
beforeEach(async () => {
vi.resetModules()
const mod = await import('./useImageUrl')
useImageUrl = mod.useImageUrl
abortSpy = vi.fn()
OriginalAbortController = global.AbortController
originalCreateObjectURL = global.URL.createObjectURL
originalRevokeObjectURL = global.URL.revokeObjectURL
originalFetch = global.fetch
global.AbortController = function () {
this.signal = 'mock-signal'
this.abort = abortSpy
}
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
global.URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
global.AbortController = OriginalAbortController
global.URL.createObjectURL = originalCreateObjectURL
global.URL.revokeObjectURL = originalRevokeObjectURL
global.fetch = originalFetch
vi.restoreAllMocks()
})
it('should return null values when url is null', () => {
const { result } = renderHook(() => useImageUrl(null))
expect(result.current.loading).toBe(false)
expect(result.current.imgUrl).toBeNull()
expect(result.current.error).toBe(false)
})
it('should return loading state initially', () => {
global.fetch = vi.fn(() => new Promise(() => {}))
const { result } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
expect(result.current.loading).toBe(true)
expect(result.current.imgUrl).toBeNull()
expect(result.current.error).toBe(false)
})
it('should fetch image and return blob URL on success', async () => {
const mockBlob = new Blob(['image-data'], { type: 'image/png' })
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
blob: () => Promise.resolve(mockBlob),
}),
)
const { result } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(result.current.loading).toBe(false)
expect(result.current.imgUrl).toBe('blob:mock-url')
expect(result.current.error).toBe(false)
expect(global.fetch).toHaveBeenCalledWith('http://example.com/img.jpg', {
signal: 'mock-signal',
})
})
it('should set error on HTTP failure', async () => {
global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }))
const { result } = renderHook(() =>
useImageUrl('http://example.com/missing.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(result.current.loading).toBe(false)
expect(result.current.imgUrl).toBeNull()
expect(result.current.error).toBe(true)
})
it('should abort fetch on unmount', async () => {
global.fetch = vi.fn(() => new Promise(() => {}))
const { unmount } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
unmount()
expect(abortSpy).toHaveBeenCalled()
})
it('should abort previous fetch when URL changes', async () => {
const abortSpies = []
global.AbortController = function () {
const spy = vi.fn()
abortSpies.push(spy)
this.signal = `signal-${abortSpies.length}`
this.abort = spy
}
const mockBlob = new Blob(['data'], { type: 'image/png' })
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
blob: () => Promise.resolve(mockBlob),
}),
)
const { rerender } = renderHook(({ url }) => useImageUrl(url), {
initialProps: { url: 'http://example.com/img1.jpg' },
})
await act(async () => {
await flushPromises()
})
rerender({ url: 'http://example.com/img2.jpg' })
expect(abortSpies[0]).toHaveBeenCalled()
})
it('should not set error on AbortError', async () => {
const abortError = new DOMException('Aborted', 'AbortError')
global.fetch = vi.fn(() => Promise.reject(abortError))
const { result } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(result.current.error).toBe(false)
})
it('should use cached blob URL on remount without re-fetching', async () => {
const mockBlob = new Blob(['data'], { type: 'image/png' })
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
blob: () => Promise.resolve(mockBlob),
}),
)
const { unmount } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
unmount()
const { result: result2 } = renderHook(() =>
useImageUrl('http://example.com/img.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result2.current.imgUrl).toBe('blob:mock-url')
expect(result2.current.loading).toBe(false)
})
it('should cache errors and not re-fetch broken URLs', async () => {
global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }))
const { unmount } = renderHook(() =>
useImageUrl('http://example.com/broken.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
unmount()
const { result: result2 } = renderHook(() =>
useImageUrl('http://example.com/broken.jpg'),
)
await act(async () => {
await flushPromises()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result2.current.error).toBe(true)
expect(result2.current.imgUrl).toBeNull()
expect(result2.current.loading).toBe(false)
})
})