a / web /src /__tests__ /lib /api.spec.ts
PNRxA
add support for episode ratings, closes #5
76b8a0b
Raw History Blame Contribute Delete
14.6 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockAuthStore = {
token: 'test-token',
refresh: vi.fn(),
logout: vi.fn(),
}
vi.mock('@/stores/auth', () => ({
useAuthStore: () => mockAuthStore,
}))
import { get, post, put, del, setOnAuthFailure, adminApi } from '@/lib/api'
const mockOnAuthFailure = vi.fn()
setOnAuthFailure(mockOnAuthFailure)
function makeFetchResponse(status: number, body: unknown = {}) {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
}
}
describe('api', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
mockAuthStore.token = 'test-token'
mockAuthStore.refresh = vi.fn()
mockAuthStore.logout = vi.fn()
mockOnAuthFailure.mockClear()
})
it('get adds Authorization header when token exists', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200, { data: 'ok' }))
vi.stubGlobal('fetch', fetchMock)
await get('/api/test')
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('/api/test')
expect(options.headers.get('Authorization')).toBe('Bearer test-token')
})
it('post sends JSON body', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await post('/api/items', { name: 'test' })
const [, options] = fetchMock.mock.calls[0]
expect(options.method).toBe('POST')
expect(options.headers.get('Content-Type')).toBe('application/json')
expect(options.body).toBe(JSON.stringify({ name: 'test' }))
})
it('del uses DELETE method', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await del('/api/items/1')
const [, options] = fetchMock.mock.calls[0]
expect(options.method).toBe('DELETE')
})
it('401 handling: attempts refresh, retries request on success', async () => {
mockAuthStore.refresh.mockResolvedValue(true)
mockAuthStore.token = 'refreshed-token'
const fetchMock = vi
.fn()
.mockResolvedValueOnce(makeFetchResponse(401))
.mockResolvedValueOnce(makeFetchResponse(200, { data: 'ok' }))
vi.stubGlobal('fetch', fetchMock)
await get('/api/protected')
expect(mockAuthStore.refresh).toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('401 + failed refresh: calls logout', async () => {
mockAuthStore.refresh.mockResolvedValue(false)
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(401))
vi.stubGlobal('fetch', fetchMock)
await get('/api/protected')
expect(mockAuthStore.refresh).toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalled()
expect(mockOnAuthFailure).toHaveBeenCalled()
})
it('non-401 error passes through without refresh', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(500, { error: 'server error' }))
vi.stubGlobal('fetch', fetchMock)
const res = await get('/api/broken')
expect(res.status).toBe(500)
expect(mockAuthStore.refresh).not.toHaveBeenCalled()
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('includes credentials in fetch calls', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await get('/api/test')
const [, options] = fetchMock.mock.calls[0]
expect(options.credentials).toBe('include')
})
it('does not set Authorization header when token is null', async () => {
mockAuthStore.token = null as unknown as string
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await get('/api/public')
const [, options] = fetchMock.mock.calls[0]
expect(options.headers.has('Authorization')).toBe(false)
})
it('network error propagates', async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))
vi.stubGlobal('fetch', fetchMock)
await expect(get('/api/test')).rejects.toThrow('Failed to fetch')
})
it('401 without token does not attempt refresh', async () => {
mockAuthStore.token = null as unknown as string
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(401))
vi.stubGlobal('fetch', fetchMock)
const res = await get('/api/protected')
expect(res.status).toBe(401)
expect(mockAuthStore.refresh).not.toHaveBeenCalled()
})
it('put sends JSON body with PUT method', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await put('/api/settings', { image_source: 'f' })
const [, options] = fetchMock.mock.calls[0]
expect(options.method).toBe('PUT')
expect(options.headers.get('Content-Type')).toBe('application/json')
expect(options.body).toBe(JSON.stringify({ image_source: 'f' }))
})
it('post without body does not set Content-Type', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await post('/api/action')
const [, options] = fetchMock.mock.calls[0]
expect(options.headers.has('Content-Type')).toBe(false)
expect(options.body).toBeUndefined()
})
it('adminApi.fetchPoster calls POST with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.fetchPoster('imdb', 'tt0111161')
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/posters/imdb/tt0111161/fetch')
expect(options.method).toBe('POST')
})
it('adminApi.fetchPoster works with tmdb id type', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.fetchPoster('tmdb', '550')
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/posters/tmdb/550/fetch')
})
it('adminApi.previewPoster calls GET with correct URL and params', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewPoster(3, 'imdb,rt,tmdb')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('/api/admin/preview/poster')
expect(url).toContain('ratings_limit=3')
expect(url).toContain('ratings_order=imdb%2Crt%2Ctmdb')
})
it('adminApi.previewPoster includes position when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewPoster(3, 'imdb,rt', 'l')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('position=l')
})
it('adminApi.previewPoster omits position when not provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewPoster(3, 'imdb,rt')
const [url] = fetchMock.mock.calls[0]
expect(url).not.toContain('position')
})
it('adminApi.previewPoster includes label_style when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewPoster(3, 'imdb,rt', 'bc', 'h', 'i')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('label_style=i')
})
it('adminApi.previewPoster includes badge_direction when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewPoster(3, 'imdb,rt', 'bc', 'h', 'i', 'v')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('badge_direction=v')
})
it('adminApi.previewPoster omits badge_direction when not provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewPoster(3, 'imdb,rt')
const [url] = fetchMock.mock.calls[0]
expect(url).not.toContain('badge_direction')
})
it('adminApi.previewLogo includes label_style when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewLogo(3, 'imdb,rt', 'h', 'i')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('label_style=i')
})
it('adminApi.previewBackdrop includes label_style when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewBackdrop(3, 'imdb,rt', 'v', 'i')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('label_style=i')
})
it('adminApi.getLogos calls GET with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.getLogos(1, 50)
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/logos?page=1&page_size=50')
})
it('adminApi.getLogoImage calls GET with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.getLogoImage('imdb/tt0111161')
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/logos/imdb/tt0111161')
})
it('adminApi.fetchLogo calls POST with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.fetchLogo('imdb', 'tt0111161')
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/logos/imdb/tt0111161/fetch')
expect(options.method).toBe('POST')
})
it('adminApi.getBackdrops calls GET with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.getBackdrops(1, 50)
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/backdrops?page=1&page_size=50')
})
it('adminApi.getBackdropImage calls GET with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.getBackdropImage('tmdb/550')
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/backdrops/tmdb/550')
})
it('adminApi.fetchBackdrop calls POST with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.fetchBackdrop('tmdb', '550')
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/backdrops/tmdb/550/fetch')
expect(options.method).toBe('POST')
})
it('adminApi.getEpisodes calls GET with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.getEpisodes(1, 50)
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/episodes?page=1&page_size=50')
})
it('adminApi.getEpisodeImage calls GET with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.getEpisodeImage('tmdb/episode-1396-S1E1')
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/episodes/tmdb/episode-1396-S1E1/image')
})
it('adminApi.fetchEpisode calls POST with correct URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.fetchEpisode('tmdb', 'episode-1396-S1E1')
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('/api/admin/episodes/tmdb/episode-1396-S1E1/fetch')
expect(options.method).toBe('POST')
})
it('adminApi.previewEpisode calls GET with correct URL and params', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewEpisode(3, 'imdb,rt,tmdb')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('/api/admin/preview/episode')
expect(url).toContain('ratings_limit=3')
expect(url).toContain('ratings_order=imdb%2Crt%2Ctmdb')
})
it('adminApi.previewEpisode includes position when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewEpisode(3, 'imdb,rt', undefined, undefined, undefined, 'tr')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('position=tr')
})
it('adminApi.previewEpisode includes badge_direction when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewEpisode(3, 'imdb,rt', undefined, undefined, undefined, 'tr', 'h')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('badge_direction=h')
})
it('adminApi.previewEpisode includes blur=true when enabled', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewEpisode(3, 'imdb,rt', undefined, undefined, undefined, undefined, undefined, true)
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('blur=true')
})
it('adminApi.previewEpisode omits blur when false', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewEpisode(3, 'imdb,rt', undefined, undefined, undefined, undefined, undefined, false)
const [url] = fetchMock.mock.calls[0]
expect(url).not.toContain('blur')
})
it('adminApi.previewEpisode includes label_style when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(makeFetchResponse(200))
vi.stubGlobal('fetch', fetchMock)
await adminApi.previewEpisode(3, 'imdb,rt', 'v', 'i')
const [url] = fetchMock.mock.calls[0]
expect(url).toContain('label_style=i')
})
})