import { object, string, number } from "yup"; import assert from "node:assert"; import { once } from "node:events"; import { createServer } from "node:http"; import { describe, it } from "node:test"; import type { IResp } from "#package/types"; import type { TRespAdapterFn } from "./index"; import { ApiClient } from "./index"; describe("ApiClient plugin", () => { it("builds endpoint paths correctly", () => { assert.equal(ApiClient.getEndpoint("/api", "users"), "/api/users"); assert.equal(ApiClient.getEndpoint("/api/", "users"), "/api/users"); }); it("handles endpoint with leading slash", () => { assert.equal(ApiClient.getEndpoint("/api", "/users"), "/api/users"); assert.equal(ApiClient.getEndpoint("/api/", "/users"), "/api//users"); }); it("handles base URL without trailing slash", () => { assert.equal(ApiClient.getEndpoint("/api/v1", "users"), "/api/v1/users"); }); it("handles empty endpoint", () => { assert.equal(ApiClient.getEndpoint("/api", ""), "/api/"); }); it("creates singleton instance", () => { const client1 = new ApiClient({ url: "/api/v1", getAdapterFn: (r) => r }); const client2 = new ApiClient({ url: "/api/v2", getAdapterFn: (r) => r }); assert.strictEqual(client1, client2); }); it("sets and gets URL correctly", () => { const client = new ApiClient({ url: "/api/v1", getAdapterFn: (r) => r }); assert.equal(client.url, "/api/v1"); client.url = "/api/v2"; assert.equal(client.url, "/api/v2"); }); it("uses default URL when not provided", () => { const originalUrl = process.env?.API_URL; process.env.API_URL = "/custom/api"; const client = new ApiClient({ url: process.env.API_URL, getAdapterFn: (r) => r }); assert.ok(client.url); if (originalUrl) { process.env.API_URL = originalUrl; } }); it("has correct event names", () => { assert.equal(ApiClient.events.success, "apiClient::success"); assert.equal(ApiClient.events.error, "apiClient::error"); }); it("provides default adapter function", () => { const testData: IResp<{ test: string; }> = { isSuccess: true, data: { test: "data", }, }; const result = ApiClient.defaults.getAdapterFn(testData); assert.strictEqual(result, testData); }); it("defines a reusable IResp data adapter", () => { const adapter: TRespAdapterFn = (resp) => { if (!resp.isSuccess || !resp.data) { throw new Error("Failed to load API"); } return resp.data; }; const response: IResp<{ value: string; }> = { isSuccess: true, data: { value: "result" }, }; assert.deepEqual(adapter(response), { value: "result" }); }); it("preserves adapter input and output types", () => { const adapter = ApiClient.defineAdapter((resp: IResp<{ value: string; }>) => { return resp.data?.value ?? "fallback"; }); assert.equal(adapter({ isSuccess: true, data: { value: "result" } }), "result"); }); it("handles complex endpoint paths", () => { assert.equal(ApiClient.getEndpoint("/api/v1", "users/123/posts"), "/api/v1/users/123/posts"); assert.equal(ApiClient.getEndpoint("/api/v1/", "users/123/posts"), "/api/v1/users/123/posts"); }); it("handles query parameters in endpoint", () => { assert.equal(ApiClient.getEndpoint("/api", "users?page=1"), "/api/users?page=1"); }); it("supports response validation chain with async yup schema", async () => { const server = createServer((req, res) => { if (req.url === "/user") { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ id: 7, name: "John" })); return; } res.statusCode = 404; res.end(JSON.stringify({})); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as { port: number; }; try { const client = new ApiClient({ url: "/api/v1", getAdapterFn: (r) => r }); client.url = `http://127.0.0.1:${port}`; const schema = object({ id: number().required(), name: string().required(), }).required(); const result = await client .get({ endpoint: "/user" }) .validate(schema); assert.equal(result.id, 7); assert.equal(result.name, "John"); } finally { server.close(); } }); it("rejects validate chain when schema validation fails", async () => { const server = createServer((req, res) => { if (req.url === "/invalid-user") { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ id: 1 })); return; } res.statusCode = 404; res.end(JSON.stringify({})); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as { port: number; }; try { const client = new ApiClient({ url: "/api/v1", getAdapterFn: (r) => r }); client.url = `http://127.0.0.1:${port}`; const schema = object({ id: number().required(), name: string().required(), }).required(); await assert.rejects( () => client.get({ endpoint: "/invalid-user" }).validate(schema), (err: unknown) => { return !!err && typeof err === "object" && "name" in err && (err as { name: string; }).name === "ValidationError"; } ); } finally { server.close(); } }); it("supports sync validation schema with validateSync", async () => { const server = createServer((req, res) => { if (req.url === "/sync-user") { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ id: 9, name: "Sync" })); return; } res.statusCode = 404; res.end(JSON.stringify({})); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as { port: number; }; try { const client = new ApiClient({ url: "/api/v1", getAdapterFn: (r) => r }); client.url = `http://127.0.0.1:${port}`; const schema = { validateSync(value: unknown) { if (!value || typeof value !== "object" || !(value as { id?: number; }).id) { throw new Error("invalid"); } return value as { id: number; name: string; }; }, }; const result = await client .get({ endpoint: "/sync-user" }) .validate(schema); assert.equal(result.id, 9); assert.equal(result.name, "Sync"); } finally { server.close(); } }); it("awaits an async adapter before emitting success", async () => { const server = createServer((req, res) => { if (req.url === "/async-adapter") { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ isSuccess: true, data: { value: "result" } })); return; } res.statusCode = 404; res.end(JSON.stringify({})); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as { port: number; }; const client = new ApiClient({ url: "/api/v1", getAdapterFn: (r) => r }); client.url = `http://127.0.0.1:${port}`; const dispatchEvent = globalThis.dispatchEvent; const CustomEventConstructor = globalThis.CustomEvent; globalThis.dispatchEvent = window.dispatchEvent.bind(window); globalThis.CustomEvent = window.CustomEvent; const successEvent = new Promise>((resolve) => { document.documentElement.addEventListener(ApiClient.events.success, (event) => { resolve(event as CustomEvent<{ resp: string; }>); }, { once: true }); }); try { const result = await client.get, string>({ endpoint: "/async-adapter", getAdapterFn: ApiClient.defineAdapter(async (resp) => { await Promise.resolve(); if (!resp.isSuccess || !resp.data) { throw new Error("Failed to load API"); } return resp.data.value; }), }); const event = await successEvent; assert.equal(result, "result"); assert.equal(event.detail.resp, "result"); } finally { globalThis.dispatchEvent = dispatchEvent; globalThis.CustomEvent = CustomEventConstructor; server.close(); } }); it("does not emit success when an async adapter rejects", async () => { const server = createServer((req, res) => { if (req.url === "/rejected-adapter") { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ isSuccess: false, errors: [ "Failed" ] })); return; } res.statusCode = 404; res.end(JSON.stringify({})); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as { port: number; }; const client = new ApiClient({ url: "/api/v1", getAdapterFn: (r) => r }); client.url = `http://127.0.0.1:${port}`; const dispatchEvent = globalThis.dispatchEvent; const CustomEventConstructor = globalThis.CustomEvent; globalThis.dispatchEvent = window.dispatchEvent.bind(window); globalThis.CustomEvent = window.CustomEvent; let successCount = 0; const onSuccess = () => { successCount += 1; }; document.documentElement.addEventListener(ApiClient.events.success, onSuccess); try { await assert.rejects( () => client.get, string>({ endpoint: "/rejected-adapter", getAdapterFn: ApiClient.defineAdapter(async () => { throw new Error("Failed to load API"); }), }), /Failed to load API/ ); assert.equal(successCount, 0); } finally { globalThis.dispatchEvent = dispatchEvent; globalThis.CustomEvent = CustomEventConstructor; document.documentElement.removeEventListener(ApiClient.events.success, onSuccess); server.close(); } }); });