Files
duty-teller/webapp/js/api.test.js
Nikolay Tatarinov 2fb553567f
Some checks failed
CI / lint-and-test (push) Failing after 45s
feat: enhance CI workflow and update webapp styles
- Added Node.js setup and webapp testing steps to the CI workflow for improved integration.
- Updated HTML to link multiple CSS files for better modularity and organization of styles.
- Removed deprecated `style.css` and introduced new CSS files for base styles, calendar, day detail, hints, markers, states, and duty list to enhance maintainability and readability.
- Implemented new styles for improved presentation of duty information and user interactions.
- Added unit tests for new API functions and contact link rendering to ensure functionality and reliability.
2026-03-02 17:20:33 +03:00

213 lines
6.8 KiB
JavaScript

/**
* Unit tests for api: buildFetchOptions, fetchDuties (403 handling, AbortError).
*/
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
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" });
});
});