Explorar o código

RHL-008-feat(api): add frontend API client and associated tests

Code_Uwe hai 20 horas
pai
achega
f31b99469e
Modificáronse 2 ficheiros con 396 adicións e 0 borrados
  1. 294 0
      lib/frontend/apiClient.js
  2. 102 0
      lib/frontend/apiClient.test.js

+ 294 - 0
lib/frontend/apiClient.js

@@ -0,0 +1,294 @@
+/**
+ * Minimal frontend-facing API client for the Lieferscheine backend.
+ *
+ * Goals:
+ * - Centralize fetch defaults (credentials + no-store) to match backend caching rules.
+ * - Provide a single, predictable error shape via ApiClientError.
+ * - Offer thin domain helpers (login/logout/branches/...).
+ *
+ * Notes:
+ * - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention".
+ * - This client is intentionally small. It is not an SDK.
+ */
+
+/**
+ * @typedef {Object} ApiErrorPayload
+ * @property {{ message: string, code: string, details?: any }} error
+ */
+
+/**
+ * A standardized client-side error type for API failures.
+ * - status: HTTP status code (e.g. 401, 403, 404, 500)
+ * - code: machine-readable error code (e.g. AUTH_UNAUTHENTICATED)
+ * - message: human-readable message (safe to show in UI)
+ * - details: optional structured payload (validation params etc.)
+ */
+export class ApiClientError extends Error {
+	/**
+	 * @param {{
+	 *   status: number,
+	 *   code: string,
+	 *   message: string,
+	 *   details?: any,
+	 *   url?: string,
+	 *   method?: string,
+	 *   cause?: any
+	 * }} input
+	 */
+	constructor({ status, code, message, details, url, method, cause }) {
+		super(message, cause ? { cause } : undefined);
+		this.name = "ApiClientError";
+		this.status = status;
+		this.code = code;
+		if (details !== undefined) this.details = details;
+		if (url) this.url = url;
+		if (method) this.method = method;
+	}
+}
+
+const DEFAULT_HEADERS = {
+	Accept: "application/json",
+};
+
+/**
+ * Resolve a request URL.
+ * - If `path` is absolute (http/https), return as-is.
+ * - If `baseUrl` is provided, resolve relative to it.
+ * - Otherwise return the relative path (browser-friendly: "/api/...").
+ *
+ * @param {string} path
+ * @param {string=} baseUrl
+ * @returns {string}
+ */
+function resolveUrl(path, baseUrl) {
+	if (/^https?:\/\//i.test(path)) return path;
+
+	const base = (baseUrl || "").trim();
+	if (!base) return path;
+
+	return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
+}
+
+/**
+ * Best-effort detection if response is JSON.
+ *
+ * @param {Response} response
+ * @returns {boolean}
+ */
+function isJsonResponse(response) {
+	const ct = response.headers.get("content-type") || "";
+	return ct.toLowerCase().includes("application/json");
+}
+
+/**
+ * Core fetch helper with:
+ * - credentials: "include" (cookie-based session)
+ * - cache: "no-store" (match backend freshness strategy)
+ * - standardized error mapping into ApiClientError
+ *
+ * @param {string} path
+ * @param {{
+ *   method?: string,
+ *   headers?: Record<string, string>,
+ *   body?: any,
+ *   baseUrl?: string,
+ *   fetchImpl?: typeof fetch
+ * }=} options
+ * @returns {Promise<any>} parsed JSON payload (or null for empty responses)
+ */
+export async function apiFetch(path, options = {}) {
+	const {
+		method = "GET",
+		headers = {},
+		body,
+		baseUrl,
+		fetchImpl = fetch,
+	} = options;
+
+	const url = resolveUrl(path, baseUrl);
+
+	// Build request init. We always set credentials + no-store.
+	// For JSON bodies, we serialize and set Content-Type.
+	const init = {
+		method,
+		credentials: "include",
+		cache: "no-store",
+		headers: { ...DEFAULT_HEADERS, ...headers },
+	};
+
+	if (body !== undefined) {
+		// If the caller passes a string, we assume it is already serialized.
+		// Otherwise we serialize as JSON.
+		if (typeof body === "string") {
+			init.body = body;
+		} else {
+			init.body = JSON.stringify(body);
+			// Only set Content-Type if caller didn't provide it.
+			if (!init.headers["Content-Type"]) {
+				init.headers["Content-Type"] = "application/json";
+			}
+		}
+	}
+
+	let response;
+	try {
+		response = await fetchImpl(url, init);
+	} catch (err) {
+		// Network errors, DNS errors, connection refused, etc.
+		throw new ApiClientError({
+			status: 0,
+			code: "CLIENT_NETWORK_ERROR",
+			message: "Network error",
+			url,
+			method,
+			cause: err,
+		});
+	}
+
+	// Handle empty responses explicitly (e.g. some endpoints might return 204 later).
+	if (response.status === 204) return null;
+
+	// Prefer JSON when the server says it's JSON.
+	if (isJsonResponse(response)) {
+		let payload;
+		try {
+			payload = await response.json();
+		} catch (err) {
+			throw new ApiClientError({
+				status: response.status,
+				code: "CLIENT_INVALID_JSON",
+				message: "Invalid JSON response",
+				url,
+				method,
+				cause: err,
+			});
+		}
+
+		if (response.ok) return payload;
+
+		/** @type {ApiErrorPayload|any} */
+		const maybeError = payload;
+
+		// Map standardized backend errors
+		if (maybeError?.error?.code && maybeError?.error?.message) {
+			throw new ApiClientError({
+				status: response.status,
+				code: maybeError.error.code,
+				message: maybeError.error.message,
+				details: maybeError.error.details,
+				url,
+				method,
+			});
+		}
+
+		// Fallback for non-standard error JSON
+		throw new ApiClientError({
+			status: response.status,
+			code: "CLIENT_HTTP_ERROR",
+			message: `Request failed with status ${response.status}`,
+			details: payload,
+			url,
+			method,
+		});
+	}
+
+	// Non-JSON response fallback (should be rare for current endpoints)
+	const text = await response.text().catch(() => "");
+	if (response.ok) return text || null;
+
+	throw new ApiClientError({
+		status: response.status,
+		code: "CLIENT_HTTP_ERROR",
+		message: text || `Request failed with status ${response.status}`,
+		url,
+		method,
+	});
+}
+
+/* -------------------------------------------------------------------------- */
+/* Domain helpers (thin wrappers)                                              */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * @param {{ username: string, password: string }} input
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function login(input, options) {
+	return apiFetch("/api/auth/login", {
+		method: "POST",
+		body: input,
+		...options,
+	});
+}
+
+/**
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function logout(options) {
+	return apiFetch("/api/auth/logout", { method: "GET", ...options });
+}
+
+/**
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function getBranches(options) {
+	return apiFetch("/api/branches", { method: "GET", ...options });
+}
+
+/**
+ * @param {string} branch
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function getYears(branch, options) {
+	return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, {
+		method: "GET",
+		...options,
+	});
+}
+
+/**
+ * @param {string} branch
+ * @param {string} year
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function getMonths(branch, year, options) {
+	return apiFetch(
+		`/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
+			year
+		)}/months`,
+		{ method: "GET", ...options }
+	);
+}
+
+/**
+ * @param {string} branch
+ * @param {string} year
+ * @param {string} month
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function getDays(branch, year, month, options) {
+	return apiFetch(
+		`/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
+			year
+		)}/${encodeURIComponent(month)}/days`,
+		{ method: "GET", ...options }
+	);
+}
+
+/**
+ * @param {string} branch
+ * @param {string} year
+ * @param {string} month
+ * @param {string} day
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function getFiles(branch, year, month, day, options) {
+	const qs = new URLSearchParams({
+		branch: String(branch),
+		year: String(year),
+		month: String(month),
+		day: String(day),
+	});
+
+	return apiFetch(`/api/files?${qs.toString()}`, { method: "GET", ...options });
+}

+ 102 - 0
lib/frontend/apiClient.test.js

@@ -0,0 +1,102 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { apiFetch, ApiClientError, getFiles, login } from "./apiClient.js";
+
+beforeEach(() => {
+	vi.restoreAllMocks();
+	global.fetch = vi.fn();
+});
+
+describe("lib/frontend/apiClient", () => {
+	it("apiFetch uses credentials=include and cache=no-store", async () => {
+		fetch.mockResolvedValue(
+			new Response(JSON.stringify({ ok: true }), {
+				status: 200,
+				headers: { "Content-Type": "application/json" },
+			})
+		);
+
+		await apiFetch("/api/health");
+
+		expect(fetch).toHaveBeenCalledTimes(1);
+		const [url, init] = fetch.mock.calls[0];
+
+		expect(url).toBe("/api/health");
+		expect(init.credentials).toBe("include");
+		expect(init.cache).toBe("no-store");
+	});
+
+	it("apiFetch serializes JSON bodies and sets Content-Type", async () => {
+		fetch.mockResolvedValue(
+			new Response(JSON.stringify({ ok: true }), {
+				status: 200,
+				headers: { "Content-Type": "application/json" },
+			})
+		);
+
+		await login({ username: "u", password: "p" });
+
+		const [, init] = fetch.mock.calls[0];
+		expect(init.method).toBe("POST");
+		expect(init.headers.Accept).toBe("application/json");
+		expect(init.headers["Content-Type"]).toBe("application/json");
+		expect(init.body).toBe(JSON.stringify({ username: "u", password: "p" }));
+	});
+
+	it("apiFetch throws ApiClientError for standardized backend error payloads", async () => {
+		fetch.mockResolvedValue(
+			new Response(
+				JSON.stringify({
+					error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+				}),
+				{ status: 401, headers: { "Content-Type": "application/json" } }
+			)
+		);
+
+		await expect(apiFetch("/api/branches")).rejects.toMatchObject({
+			name: "ApiClientError",
+			status: 401,
+			code: "AUTH_UNAUTHENTICATED",
+			message: "Unauthorized",
+		});
+	});
+
+	it("apiFetch maps network failures to CLIENT_NETWORK_ERROR", async () => {
+		fetch.mockRejectedValue(new Error("connection refused"));
+
+		try {
+			await apiFetch("/api/branches");
+			throw new Error("Expected apiFetch to throw");
+		} catch (err) {
+			expect(err).toBeInstanceOf(ApiClientError);
+			expect(err.code).toBe("CLIENT_NETWORK_ERROR");
+			expect(err.status).toBe(0);
+		}
+	});
+
+	it("getFiles builds the expected query string", async () => {
+		fetch.mockResolvedValue(
+			new Response(
+				JSON.stringify({
+					branch: "NL01",
+					year: "2024",
+					month: "10",
+					day: "23",
+					files: [],
+				}),
+				{ status: 200, headers: { "Content-Type": "application/json" } }
+			)
+		);
+
+		await getFiles("NL01", "2024", "10", "23");
+
+		const [url] = fetch.mock.calls[0];
+		// We do not rely on param ordering beyond URLSearchParams defaults.
+		expect(url).toContain("/api/files?");
+		expect(url).toContain("branch=NL01");
+		expect(url).toContain("year=2024");
+		expect(url).toContain("month=10");
+		expect(url).toContain("day=23");
+	});
+});