/** * Unit tests for api: buildFetchOptions, fetchDuties (403 handling, AbortError). */ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; beforeAll(() => { document.body.innerHTML = '

' + '
' + '
' + ''; }); const mockGetInitData = vi.fn(); vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() })); import { buildFetchOptions, fetchDuties, apiGet, fetchCalendarEvents } from "./api.js"; import { state } from "./dom.js"; describe("buildFetchOptions", () => { beforeEach(() => { state.lang = "ru"; }); it("sets X-Telegram-Init-Data when initData provided", () => { const opts = buildFetchOptions("init-data-string"); expect(opts.headers["X-Telegram-Init-Data"]).toBe("init-data-string"); opts.cleanup(); }); it("omits X-Telegram-Init-Data when initData empty", () => { const opts = buildFetchOptions(""); expect(opts.headers["X-Telegram-Init-Data"]).toBeUndefined(); opts.cleanup(); }); it("sets Accept-Language from state.lang", () => { state.lang = "en"; const opts = buildFetchOptions(""); expect(opts.headers["Accept-Language"]).toBe("en"); opts.cleanup(); }); it("returns signal and cleanup function", () => { const opts = buildFetchOptions(""); expect(opts.signal).toBeDefined(); expect(typeof opts.cleanup).toBe("function"); opts.cleanup(); }); it("cleanup clears timeout and removes external abort listener", () => { const controller = new AbortController(); const opts = buildFetchOptions("", controller.signal); opts.cleanup(); controller.abort(); }); }); describe("fetchDuties", () => { const originalFetch = globalThis.fetch; beforeEach(() => { mockGetInitData.mockReturnValue("test-init-data"); state.lang = "ru"; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("returns JSON on 200", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([{ id: 1 }]), }); const result = await fetchDuties("2025-02-01", "2025-02-28"); expect(result).toEqual([{ id: 1 }]); }); it("throws ACCESS_DENIED on 403 with server detail from body", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 403, json: () => Promise.resolve({ detail: "Custom access denied" }), }); await expect(fetchDuties("2025-02-01", "2025-02-28")).rejects.toMatchObject({ message: "ACCESS_DENIED", serverDetail: "Custom access denied", }); }); it("rethrows AbortError when request is aborted", async () => { const aborter = new AbortController(); const abortError = new DOMException("aborted", "AbortError"); globalThis.fetch = vi.fn().mockImplementation(() => { return Promise.reject(abortError); }); await expect( fetchDuties("2025-02-01", "2025-02-28", aborter.signal) ).rejects.toMatchObject({ name: "AbortError" }); }); }); describe("apiGet", () => { const originalFetch = globalThis.fetch; beforeEach(() => { mockGetInitData.mockReturnValue("init-data"); state.lang = "en"; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("builds URL with path and query params and returns response", async () => { let capturedUrl = ""; globalThis.fetch = vi.fn().mockImplementation((url) => { capturedUrl = url; return Promise.resolve({ ok: true, status: 200 }); }); await apiGet("/api/duties", { from: "2025-02-01", to: "2025-02-28" }); expect(capturedUrl).toContain("/api/duties"); expect(capturedUrl).toContain("from=2025-02-01"); expect(capturedUrl).toContain("to=2025-02-28"); }); it("omits query string when params empty", async () => { let capturedUrl = ""; globalThis.fetch = vi.fn().mockImplementation((url) => { capturedUrl = url; return Promise.resolve({ ok: true }); }); await apiGet("/api/health", {}); expect(capturedUrl).toBe(window.location.origin + "/api/health"); }); it("passes X-Telegram-Init-Data and Accept-Language headers", async () => { let capturedOpts = null; globalThis.fetch = vi.fn().mockImplementation((url, opts) => { capturedOpts = opts; return Promise.resolve({ ok: true }); }); await apiGet("/api/duties", { from: "2025-01-01", to: "2025-01-31" }); expect(capturedOpts?.headers["X-Telegram-Init-Data"]).toBe("init-data"); expect(capturedOpts?.headers["Accept-Language"]).toBe("en"); }); it("passes an abort signal to fetch when options.signal provided", async () => { const controller = new AbortController(); let capturedSignal = null; globalThis.fetch = vi.fn().mockImplementation((url, opts) => { capturedSignal = opts.signal; return Promise.resolve({ ok: true }); }); await apiGet("/api/duties", {}, { signal: controller.signal }); expect(capturedSignal).toBeDefined(); expect(capturedSignal).toBeInstanceOf(AbortSignal); }); }); describe("fetchCalendarEvents", () => { const originalFetch = globalThis.fetch; beforeEach(() => { mockGetInitData.mockReturnValue("init-data"); state.lang = "ru"; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("returns JSON array on 200", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]), }); const result = await fetchCalendarEvents("2025-02-01", "2025-02-28"); expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]); }); it("returns empty array on non-OK response", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, }); const result = await fetchCalendarEvents("2025-02-01", "2025-02-28"); expect(result).toEqual([]); }); it("returns empty array on 403 (does not throw)", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 403, }); const result = await fetchCalendarEvents("2025-02-01", "2025-02-28"); expect(result).toEqual([]); }); it("rethrows AbortError when request is aborted", async () => { const aborter = new AbortController(); const abortError = new DOMException("aborted", "AbortError"); globalThis.fetch = vi.fn().mockImplementation(() => Promise.reject(abortError)); await expect( fetchCalendarEvents("2025-02-01", "2025-02-28", aborter.signal) ).rejects.toMatchObject({ name: "AbortError" }); }); });