import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { ApiError, request, setOn401Hook } from './client'; import { getManga } from './mangas'; describe('request error envelope parsing', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); it('parses {error:{code,message}} into ApiError.code and message', async () => { fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ error: { code: 'invalid_input', message: 'title is required' } }), { status: 400, headers: { 'content-type': 'application/json' } } ) ); await expect(getManga('x')).rejects.toMatchObject({ status: 400, code: 'invalid_input', message: 'title is required' }); }); it('falls back to http_error code when body is HTML (e.g. upstream proxy)', async () => { fetchSpy.mockResolvedValueOnce( new Response('upstream proxy bad', { status: 502, headers: { 'content-type': 'text/html' } }) ); const err = (await getManga('x').catch((e) => e)) as ApiError; expect(err).toBeInstanceOf(ApiError); expect(err.status).toBe(502); expect(err.code).toBe('http_error'); expect(err.message).toContain('upstream proxy bad'); }); it('falls back to http_error code when body is empty', async () => { fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 })); const err = (await getManga('x').catch((e) => e)) as ApiError; expect(err).toBeInstanceOf(ApiError); expect(err.status).toBe(500); expect(err.code).toBe('http_error'); }); it('treats empty 200/201 bodies as undefined (no JSON.parse crash)', async () => { // Regression: addMangaToCollection is typed `void` and the // backend returns 201 (created) / 200 (already there) with // no body. Without the empty-body short-circuit, `res.json()` // would throw `JSON.parse: unexpected end of data`. fetchSpy.mockResolvedValueOnce(new Response(null, { status: 201 })); const created = await request('/v1/whatever', { method: 'POST' }); expect(created).toBeUndefined(); fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 })); const ok200 = await request('/v1/whatever', { method: 'POST' }); expect(ok200).toBeUndefined(); }); it('falls back to http_error code when JSON has no error envelope', async () => { fetchSpy.mockResolvedValueOnce( new Response(JSON.stringify({ message: 'oops' }), { status: 500, headers: { 'content-type': 'application/json' } }) ); const err = (await getManga('x').catch((e) => e)) as ApiError; expect(err.code).toBe('http_error'); }); }); describe('on401 hook', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); // Critical: reset the module-level hook between tests so a // hook installed by one test doesn't leak into the next. setOn401Hook(null); }); it('invokes the hook exactly once on a 401 response and re-throws', async () => { const hook = vi.fn(); setOn401Hook(hook); fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ error: { code: 'unauthenticated', message: 'no auth' } }), { status: 401, headers: { 'content-type': 'application/json' } } ) ); await expect(getManga('x')).rejects.toMatchObject({ status: 401, code: 'unauthenticated' }); expect(hook).toHaveBeenCalledTimes(1); }); it('does not invoke the hook on non-401 errors', async () => { const hook = vi.fn(); setOn401Hook(hook); fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ error: { code: 'not_found', message: 'no' } }), { status: 404, headers: { 'content-type': 'application/json' } } ) ); await expect(getManga('x')).rejects.toMatchObject({ status: 404 }); expect(hook).not.toHaveBeenCalled(); }); it('does not invoke the hook on successful responses', async () => { const hook = vi.fn(); setOn401Hook(hook); fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ id: 'm1', title: 't', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [] }), { status: 200, headers: { 'content-type': 'application/json' } } ) ); await getManga('m1'); expect(hook).not.toHaveBeenCalled(); }); it('swallows hook exceptions so the original ApiError still propagates', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); setOn401Hook(() => { throw new Error('hook boom'); }); fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ error: { code: 'unauthenticated', message: 'x' } }), { status: 401, headers: { 'content-type': 'application/json' } } ) ); await expect(getManga('x')).rejects.toMatchObject({ status: 401 }); // The original ApiError won — the hook's panic was logged but // didn't replace the API error. expect(consoleSpy).toHaveBeenCalled(); }); });