Browse Source

RHL-020 feat(apiClient): add getMe function to retrieve current session identity and corresponding tests

Code_Uwe 1 tháng trước cách đây
mục cha
commit
dc6ca8bcdb
2 tập tin đã thay đổi với 98 bổ sung4 xóa
  1. 57 3
      lib/frontend/apiClient.js
  2. 41 1
      lib/frontend/apiClient.test.js

+ 57 - 3
lib/frontend/apiClient.js

@@ -4,7 +4,7 @@
  * 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/...).
+ * - Offer thin domain helpers (login/logout/me/branches/...).
  *
  * Notes:
  * - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention".
@@ -36,10 +36,14 @@ export class ApiClientError extends Error {
 	 * }} input
 	 */
 	constructor({ status, code, message, details, url, method, cause }) {
+		// We attach `cause` to preserve error chains (supported in modern Node).
 		super(message, cause ? { cause } : undefined);
+
 		this.name = "ApiClientError";
 		this.status = status;
 		this.code = code;
+
+		// Only attach optional properties when provided (keeps error objects clean).
 		if (details !== undefined) this.details = details;
 		if (url) this.url = url;
 		if (method) this.method = method;
@@ -56,22 +60,36 @@ const DEFAULT_HEADERS = {
  * - If `baseUrl` is provided, resolve relative to it.
  * - Otherwise return the relative path (browser-friendly: "/api/...").
  *
+ * Why this exists:
+ * - The same client can be used:
+ *   - in the browser (relative "/api/..." calls)
+ *   - in Node scripts (absolute baseUrl like "http://127.0.0.1:3000")
+ *
  * @param {string} path
  * @param {string=} baseUrl
  * @returns {string}
  */
 function resolveUrl(path, baseUrl) {
+	// If someone passes a full URL, keep it unchanged.
 	if (/^https?:\/\//i.test(path)) return path;
 
 	const base = (baseUrl || "").trim();
+
+	// Browser usage: baseUrl omitted -> use relative path.
 	if (!base) return path;
 
+	// Ensure baseUrl ends with a slash so URL() resolves correctly.
 	return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
 }
 
 /**
  * Best-effort detection if response is JSON.
  *
+ * Why we need this:
+ * - Our API is intended to always respond with JSON.
+ * - But in error cases (misconfig, reverse proxy, 404 HTML), we might receive non-JSON.
+ * - We want robust parsing behavior and a predictable client-side error.
+ *
  * @param {Response} response
  * @returns {boolean}
  */
@@ -86,6 +104,10 @@ function isJsonResponse(response) {
  * - cache: "no-store" (match backend freshness strategy)
  * - standardized error mapping into ApiClientError
  *
+ * Clean code rule:
+ * - UI code should NOT call fetch directly.
+ * - Instead, it should call domain helpers that route through apiFetch().
+ *
  * @param {string} path
  * @param {{
  *   method?: string,
@@ -123,7 +145,9 @@ export async function apiFetch(path, options = {}) {
 			init.body = body;
 		} else {
 			init.body = JSON.stringify(body);
+
 			// Only set Content-Type if caller didn't provide it.
+			// This allows callers to send non-JSON payloads later if needed.
 			if (!init.headers["Content-Type"]) {
 				init.headers["Content-Type"] = "application/json";
 			}
@@ -135,6 +159,7 @@ export async function apiFetch(path, options = {}) {
 		response = await fetchImpl(url, init);
 	} catch (err) {
 		// Network errors, DNS errors, connection refused, etc.
+		// We use status=0 to indicate "no HTTP response".
 		throw new ApiClientError({
 			status: 0,
 			code: "CLIENT_NETWORK_ERROR",
@@ -151,9 +176,11 @@ export async function apiFetch(path, options = {}) {
 	// Prefer JSON when the server says it's JSON.
 	if (isJsonResponse(response)) {
 		let payload;
+
 		try {
 			payload = await response.json();
 		} catch (err) {
+			// Server said JSON but response body was not parseable JSON.
 			throw new ApiClientError({
 				status: response.status,
 				code: "CLIENT_INVALID_JSON",
@@ -164,12 +191,13 @@ export async function apiFetch(path, options = {}) {
 			});
 		}
 
+		// Happy path: return parsed JSON.
 		if (response.ok) return payload;
 
 		/** @type {ApiErrorPayload|any} */
 		const maybeError = payload;
 
-		// Map standardized backend errors
+		// Map standardized backend errors into ApiClientError
 		if (maybeError?.error?.code && maybeError?.error?.message) {
 			throw new ApiClientError({
 				status: response.status,
@@ -181,7 +209,7 @@ export async function apiFetch(path, options = {}) {
 			});
 		}
 
-		// Fallback for non-standard error JSON
+		// Fallback: error is JSON but not in our standardized shape.
 		throw new ApiClientError({
 			status: response.status,
 			code: "CLIENT_HTTP_ERROR",
@@ -194,6 +222,7 @@ export async function apiFetch(path, options = {}) {
 
 	// 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({
@@ -210,6 +239,10 @@ export async function apiFetch(path, options = {}) {
 /* -------------------------------------------------------------------------- */
 
 /**
+ * Login:
+ * - Sends credentials to the backend.
+ * - Backend sets an HTTP-only cookie when successful.
+ *
  * @param {{ username: string, password: string }} input
  * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  */
@@ -222,6 +255,9 @@ export function login(input, options) {
 }
 
 /**
+ * Logout:
+ * - Clears the session cookie (idempotent).
+ *
  * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  */
 export function logout(options) {
@@ -229,6 +265,24 @@ export function logout(options) {
 }
 
 /**
+ * Get current session identity (frontend-friendly):
+ * - Always returns HTTP 200 with:
+ *   - { user: null } when unauthenticated
+ *   - { user: { userId, role, branchId } } when authenticated
+ *
+ * Why we want this:
+ * - The UI should not use 401 as basic control-flow to determine "am I logged in?"
+ * - This endpoint enables a clean "session check" UX (RHL-020 AuthGate).
+ *
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
+ */
+export function getMe(options) {
+	return apiFetch("/api/auth/me", { method: "GET", ...options });
+}
+
+/**
+ * List branches visible to the current session (RBAC is enforced server-side).
+ *
  * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  */
 export function getBranches(options) {

+ 41 - 1
lib/frontend/apiClient.test.js

@@ -1,10 +1,23 @@
 /* @vitest-environment node */
 
 import { describe, it, expect, vi, beforeEach } from "vitest";
-import { apiFetch, ApiClientError, getFiles, login } from "./apiClient.js";
+import {
+	apiFetch,
+	ApiClientError,
+	getFiles,
+	login,
+	getMe,
+} from "./apiClient.js";
 
 beforeEach(() => {
+	// Restore mocks between tests to avoid cross-test pollution.
 	vi.restoreAllMocks();
+
+	// In these unit tests we stub the global fetch implementation.
+	// This allows us to validate:
+	// - request defaults (credentials/cache)
+	// - URL building
+	// - error mapping
 	global.fetch = vi.fn();
 });
 
@@ -99,4 +112,31 @@ describe("lib/frontend/apiClient", () => {
 		expect(url).toContain("month=10");
 		expect(url).toContain("day=23");
 	});
+
+	it("getMe calls /api/auth/me and returns the parsed payload", async () => {
+		// /api/auth/me returns 200 with { user: null } or { user: {...} }.
+		// For this unit test we only need to ensure:
+		// - correct endpoint
+		// - correct method
+		// - response is returned as parsed JSON
+		fetch.mockResolvedValue(
+			new Response(JSON.stringify({ user: null }), {
+				status: 200,
+				headers: { "Content-Type": "application/json" },
+			})
+		);
+
+		const res = await getMe();
+
+		expect(res).toEqual({ user: null });
+
+		expect(fetch).toHaveBeenCalledTimes(1);
+		const [url, init] = fetch.mock.calls[0];
+		expect(url).toBe("/api/auth/me");
+		expect(init.method).toBe("GET");
+
+		// Ensure our global defaults are still enforced for session-based calls.
+		expect(init.credentials).toBe("include");
+		expect(init.cache).toBe("no-store");
+	});
 });