import { afterEach, describe, expect, it, vi } from "vitest"; import { api, ApiFallbackError, getApiFallback } from "./client"; afterEach(() => { vi.unstubAllGlobals(); }); describe("api fallback helpers", () => { it("extracts typed fallback payloads", () => { expect(getApiFallback(new ApiFallbackError("offline", ["demo"]))).toEqual(["demo"]); }); it("ignores non fallback errors", () => { expect(getApiFallback(new Error("boom"))).toBeUndefined(); }); it("does not send JSON content-type for bodyless delete requests", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }) ); vi.stubGlobal("fetch", fetchMock); await api.deleteLibrary(42); const init = fetchMock.mock.calls[0][1] as RequestInit; const headers = new Headers(init.headers); expect(init.method).toBe("DELETE"); expect(headers.has("Content-Type")).toBe(false); }); it("surfaces create library API errors without fallback", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ message: "Library path does not exist" }), { status: 400, statusText: "Bad Request", headers: { "Content-Type": "application/json" } }) ); vi.stubGlobal("fetch", fetchMock); await expect(api.createLibrary({ name: "Books", path: "/missing", enabled: true })).rejects.toThrow("Library path does not exist"); }); it("does not fallback when scan enqueue fails", async () => { const fetchMock = vi.fn().mockRejectedValue(new Error("offline")); vi.stubGlobal("fetch", fetchMock); await expect(api.scanLibrary(42)).rejects.toThrow("offline"); }); it("keeps reader preferences locally when the backend contract is absent", async () => { const storage = new Map(); vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key), clear: () => storage.clear() }); const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404, statusText: "Not Found" })); vi.stubGlobal("fetch", fetchMock); await expect(api.readerPreferences(8)).resolves.toEqual({ mode: "horizontal", fit: "page" }); await expect(api.saveReaderPreferences(8, { mode: "vertical", fit: "width" })).resolves.toEqual({ mode: "vertical", fit: "width" }); expect(storage.get("readabook:reader-preferences:8")).toBe(JSON.stringify({ mode: "vertical", fit: "width" })); }); it("sends metadata source updates to the admin endpoint", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), { status: 200, headers: { "Content-Type": "application/json" } }) ); vi.stubGlobal("fetch", fetchMock); await api.updateMetadataSources({ isbnPriorityEnabled: false, sources: [{ provider: "openlibrary", enabled: true, priority: 1 }] }); expect(fetchMock.mock.calls[0][0]).toBe("/admin/metadata-sources"); const init = fetchMock.mock.calls[0][1] as RequestInit; expect(init.method).toBe("PUT"); expect(JSON.parse(init.body as string)).toEqual({ isbnPriorityEnabled: false, sources: [{ provider: "openlibrary", enabled: true, priority: 1 }] }); }); it("sends automation settings to the admin endpoint", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ watchLibraries: true, autoEnrichNewBooks: true, scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }, enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 } }), { status: 200, headers: { "Content-Type": "application/json" } } ) ); vi.stubGlobal("fetch", fetchMock); await api.updateAutomationSettings({ watchLibraries: true, scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 } }); expect(fetchMock.mock.calls[0][0]).toBe("/admin/automation"); const init = fetchMock.mock.calls[0][1] as RequestInit; expect(init.method).toBe("PUT"); expect(JSON.parse(init.body as string)).toEqual({ watchLibraries: true, scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 } }); }); });