import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { createBookmark, deleteBookmark, listMyBookmarks, listMyBookmarksOrEmpty } from './bookmarks'; function ok(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); } function noContent(): Response { return new Response(null, { status: 204 }); } function envelope(status: number, code: string, message: string): Response { return new Response(JSON.stringify({ error: { code, message } }), { status, headers: { 'content-type': 'application/json' } }); } const bookmarkFixture = { id: 'b1', user_id: 'u1', manga_id: 'm1', chapter_id: null, page: null, created_at: '2026-01-01T00:00:00Z' }; describe('bookmarks api client', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); it('createBookmark POSTs JSON to /v1/bookmarks', async () => { fetchSpy.mockResolvedValueOnce(ok(bookmarkFixture, 201)); const b = await createBookmark({ manga_id: 'm1' }); expect(b).toEqual(bookmarkFixture); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/bookmarks$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm1' }); }); it('createBookmark surfaces 409 conflict', async () => { fetchSpy.mockResolvedValueOnce(envelope(409, 'conflict', 'already bookmarked')); await expect(createBookmark({ manga_id: 'm1' })).rejects.toMatchObject({ status: 409, code: 'conflict' }); }); it('deleteBookmark DELETEs /v1/bookmarks/{id} and handles 204', async () => { fetchSpy.mockResolvedValueOnce(noContent()); await expect(deleteBookmark('b1')).resolves.toBeUndefined(); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/bookmarks\/b1$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('DELETE'); }); it('listMyBookmarks hits /v1/me/bookmarks and returns paged envelope', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [bookmarkFixture], page: { limit: 50, offset: 0, total: null } }) ); const result = await listMyBookmarks(); expect(result.items).toHaveLength(1); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/me\/bookmarks$/); }); it('listMyBookmarksOrEmpty returns empty page on 401', async () => { fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated')); const result = await listMyBookmarksOrEmpty(); expect(result.items).toEqual([]); }); it('listMyBookmarksOrEmpty re-throws non-401', async () => { fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'oops')); await expect(listMyBookmarksOrEmpty()).rejects.toMatchObject({ status: 500 }); }); });