import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { ApiError, request, setOn401Hook, fileUrl } from './client'; import { getManga } from './mangas'; describe('fileUrl', () => { it('keeps a normal /-separated key path literal', () => { expect(fileUrl('mangas/abc/chapters/def/pages/0001.jpg')).toBe( '/api/v1/files/mangas/abc/chapters/def/pages/0001.jpg' ); }); it('percent-encodes reserved characters within a segment', () => { // `?`, `#`, `%`, and spaces inside a segment must be encoded so they // can't be reinterpreted as query/fragment delimiters — while the // slashes stay as literal path separators. expect(fileUrl('weird key/a?b#c%d/x y.png')).toBe( '/api/v1/files/weird%20key/a%3Fb%23c%25d/x%20y.png' ); }); }); 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 a 401 when suppressOn401 is set', async () => { // The change-password endpoint returns 401 for a *wrong current // password*, not an expired session — clearing the cached user // there would spuriously log the (still-authenticated) user out. // Such calls opt out of the hook while still throwing the 401. const hook = vi.fn(); setOn401Hook(hook); fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ error: { code: 'unauthenticated', message: 'wrong password' } }), { status: 401, headers: { 'content-type': 'application/json' } } ) ); await expect( request('/v1/auth/me/password', { method: 'PATCH' }, { suppressOn401: true }) ).rejects.toMatchObject({ status: 401, code: 'unauthenticated' }); expect(hook).not.toHaveBeenCalled(); }); 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(); }); });