Prechádzať zdrojové kódy

RHL-007-feat(tests): implement standardized error handling tests for API responses

Code_Uwe 18 hodín pred
rodič
commit
f3542e2856
2 zmenil súbory, kde vykonal 244 pridanie a 0 odobranie
  1. 178 0
      lib/api/errors.js
  2. 66 0
      lib/api/errors.test.js

+ 178 - 0
lib/api/errors.js

@@ -0,0 +1,178 @@
+/**
+ * Central API response helpers for consistent JSON output and standardized errors.
+ *
+ * Why this exists:
+ * - Route handlers should NOT each invent their own error JSON shape.
+ * - The frontend (and tests/logging) should rely on a stable contract:
+ *   { error: { message, code, details? } }
+ * - We want consistent HTTP status codes (400/401/403/404/500).
+ *
+ * This file intentionally uses the Web Fetch `Response` object (not NextResponse),
+ * because Next.js route handlers can return any `Response` and it keeps tests simple.
+ */
+
+/** Default JSON response headers for all API responses that return JSON. */
+const JSON_HEADERS = { "Content-Type": "application/json" };
+
+/**
+ * Create a JSON `Response` with the given payload.
+ *
+ * @param {any} data - The JSON-serializable payload.
+ * @param {number} status - HTTP status code.
+ * @param {Record<string,string>} headers - Optional extra headers.
+ * @returns {Response}
+ */
+export function json(data, status = 200, headers = {}) {
+	return new Response(JSON.stringify(data), {
+		status,
+		headers: { ...JSON_HEADERS, ...headers },
+	});
+}
+
+/**
+ * Build the standardized error payload.
+ *
+ * Contract:
+ * {
+ *   "error": {
+ *     "message": "Human readable message",
+ *     "code": "SOME_MACHINE_CODE",
+ *     "details": { ... } // optional
+ *   }
+ * }
+ *
+ * IMPORTANT:
+ * - message is for humans (UI/logs).
+ * - code is for machines (frontend decisions, monitoring, tests).
+ * - details is optional and should only include safe data.
+ *
+ * @param {{code:string, message:string, details?:any}} input
+ * @returns {{error:{message:string, code:string, details?:any}}}
+ */
+export function buildErrorPayload({ code, message, details }) {
+	const error = { message, code };
+
+	// Only include details when explicitly provided.
+	// This keeps the error payload minimal and predictable.
+	if (details !== undefined) error.details = details;
+
+	return { error };
+}
+
+/**
+ * Create a standardized JSON error response.
+ *
+ * @param {number} status - HTTP status code (400/401/403/404/500).
+ * @param {string} code - Stable machine-readable error code.
+ * @param {string} message - Human-readable message (safe to expose).
+ * @param {any=} details - Optional structured details (validation fields, params, etc.).
+ * @returns {Response}
+ */
+export function jsonError(status, code, message, details) {
+	return json(buildErrorPayload({ code, message, details }), status);
+}
+
+/**
+ * Custom error type for “expected” API failures.
+ *
+ * We throw ApiError from route handlers instead of returning ad-hoc JSON.
+ * The `withErrorHandling` wrapper converts it into a standardized response.
+ */
+export class ApiError extends Error {
+	/**
+	 * @param {{
+	 *   status: number,
+	 *   code: string,
+	 *   message: string,
+	 *   details?: any,
+	 *   cause?: any
+	 * }} input
+	 */
+	constructor({ status, code, message, details, cause }) {
+		// Modern Node supports `cause`, which preserves the original error chain.
+		super(message, cause ? { cause } : undefined);
+
+		this.name = "ApiError";
+		this.status = status;
+		this.code = code;
+
+		// Only attach details if explicitly provided.
+		if (details !== undefined) this.details = details;
+	}
+}
+
+/**
+ * Convenience constructors for the common HTTP statuses used by the API.
+ * This prevents magic numbers and keeps code readable in route handlers.
+ */
+
+export function badRequest(code, message, details) {
+	return new ApiError({ status: 400, code, message, details });
+}
+
+export function unauthorized(code, message, details) {
+	return new ApiError({ status: 401, code, message, details });
+}
+
+export function forbidden(code, message, details) {
+	return new ApiError({ status: 403, code, message, details });
+}
+
+export function notFound(code, message, details) {
+	return new ApiError({ status: 404, code, message, details });
+}
+
+/**
+ * Convert a thrown error into a standardized API response.
+ *
+ * Policy:
+ * - If it's an ApiError, we trust its status/code/message (they should be safe).
+ * - Otherwise, we return a generic 500 without leaking internals.
+ *
+ * @param {unknown} err
+ * @returns {Response}
+ */
+export function toApiErrorResponse(err) {
+	if (err instanceof ApiError) {
+		return jsonError(err.status, err.code, err.message, err.details);
+	}
+
+	// Never leak unexpected error details to clients.
+	return jsonError(500, "INTERNAL_SERVER_ERROR", "Internal server error");
+}
+
+/**
+ * Wrap a Next.js route handler to enforce consistent error handling.
+ *
+ * Usage:
+ * export const GET = withErrorHandling(async function GET(request, ctx) { ... })
+ *
+ * Benefits:
+ * - No repeated try/catch blocks in every route.
+ * - All thrown ApiErrors become standardized JSON responses.
+ * - Unknown errors become a safe, generic 500 response.
+ *
+ * @param {Function} handler
+ * @param {{logPrefix?:string}=} options
+ * @returns {Function}
+ */
+export function withErrorHandling(handler, { logPrefix = "[api]" } = {}) {
+	return async (...args) => {
+		try {
+			return await handler(...args);
+		} catch (err) {
+			// Log unexpected errors always.
+			// For ApiErrors, only log server-side failures (5xx) by default
+			// to avoid noisy logs for client mistakes (4xx).
+			if (err instanceof ApiError) {
+				if (err.status >= 500) {
+					console.error(`${logPrefix} ${err.code}:`, err.cause || err);
+				}
+				return toApiErrorResponse(err);
+			}
+
+			console.error(`${logPrefix} Unhandled error:`, err);
+			return toApiErrorResponse(err);
+		}
+	};
+}

+ 66 - 0
lib/api/errors.test.js

@@ -0,0 +1,66 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { jsonError, withErrorHandling, badRequest } from "./errors.js";
+
+describe("lib/api/errors", () => {
+	it("jsonError returns the standardized error shape without details", async () => {
+		const res = jsonError(401, "AUTH_UNAUTHENTICATED", "Unauthorized");
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("jsonError includes details when provided", async () => {
+		const res = jsonError(
+			400,
+			"VALIDATION_MISSING_PARAM",
+			"Missing required route parameter(s)",
+			{ params: ["branch"] }
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing required route parameter(s)",
+				code: "VALIDATION_MISSING_PARAM",
+				details: { params: ["branch"] },
+			},
+		});
+	});
+
+	it("withErrorHandling converts ApiError into a standardized response", async () => {
+		// The wrapped handler throws an expected error (ApiError).
+		// The wrapper must convert it into { error: { message, code } } with status 400.
+		const handler = withErrorHandling(async () => {
+			throw badRequest("VALIDATION_TEST", "Bad Request");
+		});
+
+		const res = await handler();
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: { message: "Bad Request", code: "VALIDATION_TEST" },
+		});
+	});
+
+	it("withErrorHandling converts unknown errors into a safe 500 response", async () => {
+		// Unknown errors must never leak internal messages/stacks.
+		// We always return a generic 500 payload.
+		const handler = withErrorHandling(async () => {
+			throw new Error("boom");
+		});
+
+		const res = await handler();
+
+		expect(res.status).toBe(500);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Internal server error",
+				code: "INTERNAL_SERVER_ERROR",
+			},
+		});
+	});
+});