@hyper-fetch/core
Version:
Cache, Queue and Persist your requests no matter if you are online or offline!
424 lines (356 loc) • 13 kB
text/typescript
/**
* @vitest-environment node
*/
import { URL } from "url";
import { Client } from "@hyper-fetch/core";
import type { RouteHandler } from "@hyper-fetch/testing";
import {
createE2EServer,
jsonHandler,
redirectHandler,
streamHandler,
fileDownloadHandler,
echoHandler,
} from "@hyper-fetch/testing";
const { route, startServer, stopServer } = createE2EServer();
let baseUrl: string;
beforeAll(async () => {
// Query Params
route("GET", "/search", (req, res) => {
const url = new URL(req.url || "/", `http://localhost`);
const params: Record<string, string> = {};
url.searchParams.forEach((value: string, key: string) => {
params[key] = value;
});
const body = JSON.stringify({ params });
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body).toString() });
res.end(body);
});
// URL Params
// eslint-disable-next-line max-params
const usersHandler: RouteHandler = (req, res, _body, params) => {
const responseBody = JSON.stringify({ id: Number(params.id), name: params.id === "1" ? "Alice" : "Bob" });
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(responseBody).toString(),
});
res.end(responseBody);
};
route("GET", "/users/:id", usersHandler);
// eslint-disable-next-line max-params
const orgsHandler: RouteHandler = (_req, res, _body, params) => {
const responseBody = JSON.stringify({ org: params.orgId, repo: params.repoId });
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(responseBody).toString(),
});
res.end(responseBody);
};
route("GET", "/orgs/:orgId/repos/:repoId", orgsHandler);
// Redirects
route("GET", "/redirect-302", redirectHandler("/final"));
route("GET", "/redirect-301", redirectHandler("/final", 301));
route("GET", "/redirect-chain", redirectHandler("/redirect-302"));
route("GET", "/final", jsonHandler({ redirected: true }));
// Streaming
route("GET", "/stream", streamHandler(["chunk1", "chunk2", "chunk3"], 30));
route(
"GET",
"/stream-large",
streamHandler(
Array.from({ length: 20 }, (_, i) => `data-${i}\n`),
10,
),
);
// File Download
const fileContent = Buffer.from("Hello, this is a test file content!");
route("GET", "/download/test.txt", fileDownloadHandler(fileContent, "test.txt", "text/plain"));
const binaryContent = Buffer.alloc(1024, 0xab);
route("GET", "/download/binary.bin", fileDownloadHandler(binaryContent, "binary.bin"));
// Concurrent
route("GET", "/item/1", jsonHandler({ id: 1 }));
route("GET", "/item/2", jsonHandler({ id: 2 }));
route("GET", "/item/3", jsonHandler({ id: 3 }));
// Callbacks
route("POST", "/callback-test", echoHandler);
// Validation
route("POST", "/validate", (req, res, body) => {
try {
const parsed = JSON.parse(body.toString());
if (!parsed.email) {
const errBody = JSON.stringify({ errors: { email: "Email is required" } });
res.writeHead(422, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(errBody).toString(),
});
res.end(errBody);
return;
}
const okBody = JSON.stringify({ success: true });
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(okBody).toString(),
});
res.end(okBody);
} catch {
const errBody = JSON.stringify({ message: "Invalid JSON" });
res.writeHead(400, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(errBody).toString(),
});
res.end(errBody);
}
});
baseUrl = await startServer();
});
afterAll(async () => {
await stopServer();
});
describe("E2E [ Query Params ]", () => {
it("should send query params", async () => {
const client = new Client({ url: baseUrl });
const request = client
.createRequest<{
response: { params: Record<string, string> };
queryParams: { q: string; page: string };
}>()({
endpoint: "/search",
method: "GET",
})
.setQueryParams({ q: "hyper-fetch", page: "1" });
const { data, error } = await request.send();
expect(error).toBeNull();
expect(data!.params.q).toBe("hyper-fetch");
expect(data!.params.page).toBe("1");
});
it("should handle special characters in query params", async () => {
const client = new Client({ url: baseUrl });
const request = client
.createRequest<{
response: { params: Record<string, string> };
queryParams: { q: string };
}>()({
endpoint: "/search",
method: "GET",
})
.setQueryParams({ q: "hello world&foo=bar" });
const { data, error } = await request.send();
expect(error).toBeNull();
expect(data!.params.q).toBe("hello world&foo=bar");
});
});
describe("E2E [ URL Params ]", () => {
it("should resolve single URL param", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: { id: number; name: string } }>()({
endpoint: "/users/:id",
method: "GET",
});
const { data, error } = await request.setParams({ id: "1" }).send();
expect(error).toBeNull();
expect(data).toStrictEqual({ id: 1, name: "Alice" });
});
it("should resolve multiple URL params", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: { org: string; repo: string } }>()({
endpoint: "/orgs/:orgId/repos/:repoId",
method: "GET",
});
const { data, error } = await request.setParams({ orgId: "acme", repoId: "hyper-fetch" }).send();
expect(error).toBeNull();
expect(data).toStrictEqual({ org: "acme", repo: "hyper-fetch" });
});
});
describe("E2E [ Redirects ]", () => {
it("should follow 302 redirect", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: { redirected: boolean } }>()({
endpoint: "/redirect-302",
method: "GET",
});
const { data, error, status } = await request.send();
expect(error).toBeNull();
expect(status).toBe(200);
expect(data).toStrictEqual({ redirected: true });
});
it("should follow 301 redirect", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: { redirected: boolean } }>()({
endpoint: "/redirect-301",
method: "GET",
});
const { data, error, status } = await request.send();
expect(error).toBeNull();
expect(status).toBe(200);
expect(data).toStrictEqual({ redirected: true });
});
it("should follow redirect chain", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: { redirected: boolean } }>()({
endpoint: "/redirect-chain",
method: "GET",
});
const { data, error, status } = await request.send();
expect(error).toBeNull();
expect(status).toBe(200);
expect(data).toStrictEqual({ redirected: true });
});
});
describe("E2E [ Streaming ]", () => {
it("should receive streamed response", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: any }>()({
endpoint: "/stream",
method: "GET",
});
const { data, error, status } = await request.send();
expect(error).toBeNull();
expect(status).toBe(200);
expect(data).toBe("chunk1chunk2chunk3");
});
it("should report download progress for streamed response", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: any }>()({
endpoint: "/stream-large",
method: "GET",
});
const progressEvents: number[] = [];
const { data, error, status } = await request.send({
onDownloadProgress: ({ loaded }) => {
progressEvents.push(loaded);
},
});
expect(error).toBeNull();
expect(status).toBe(200);
expect(data).toBeDefined();
expect(progressEvents.length).toBeGreaterThanOrEqual(1);
const lastLoaded = progressEvents[progressEvents.length - 1];
expect(lastLoaded).toBeGreaterThan(0);
});
it("should return ReadableStream when streaming option is enabled", async () => {
const client = new Client({ url: baseUrl });
const request = client
.createRequest<{ response: any }>()({
endpoint: "/stream",
method: "GET",
})
.setOptions({ streaming: true });
const { data, error, status } = await request.send();
expect(error).toBeNull();
expect(status).toBe(200);
expect(data).toBeDefined();
expect(typeof (data as any).getReader).toBe("function");
const reader = (data as any as ReadableStream<Uint8Array>).getReader();
const chunks: string[] = [];
const decoder = new TextDecoder();
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
expect(chunks.join("")).toBe("chunk1chunk2chunk3");
});
});
describe("E2E [ File Download ]", () => {
it("should download text file", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: any }>()({
endpoint: "/download/test.txt",
method: "GET",
});
const { data, error, status, extra } = await request.send();
expect(error).toBeNull();
expect(status).toBe(200);
expect(extra!.headers["content-disposition"]).toContain("test.txt");
expect(data).toBe("Hello, this is a test file content!");
});
it("should download binary file with correct size", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{ response: any }>()({
endpoint: "/download/binary.bin",
method: "GET",
});
const { error, status, extra } = await request.send();
expect(error).toBeNull();
expect(status).toBe(200);
expect(extra!.headers["content-length"]).toBe("1024");
expect(extra!.headers["content-disposition"]).toContain("binary.bin");
});
});
describe("E2E [ Concurrent Requests ]", () => {
it("should handle multiple concurrent requests", async () => {
const client = new Client({ url: baseUrl });
const requests = [1, 2, 3].map((id) =>
client
.createRequest<{ response: { id: number } }>()({
endpoint: `/item/${id}`,
method: "GET",
})
.send(),
);
const results = await Promise.all(requests);
results.forEach((result, index) => {
expect(result.error).toBeNull();
expect(result.status).toBe(200);
expect(result.data).toStrictEqual({ id: index + 1 });
});
});
});
describe("E2E [ Request Callbacks ]", () => {
it("should fire lifecycle callbacks in order", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{
response: { body: { test: boolean } };
payload: { test: boolean };
}>()({
endpoint: "/callback-test",
method: "POST",
});
const callOrder: string[] = [];
await request.send({
payload: { test: true },
onRequestStart: () => {
callOrder.push("requestStart");
},
onResponse: () => {
callOrder.push("response");
},
});
expect(callOrder).toContain("requestStart");
expect(callOrder).toContain("response");
expect(callOrder.indexOf("requestStart")).toBeLessThan(callOrder.indexOf("response"));
});
});
describe("E2E [ Error Responses ]", () => {
it("should return structured validation error", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{
response: { success: boolean };
payload: { email?: string };
error: { errors: { email: string } };
}>()({
endpoint: "/validate",
method: "POST",
});
const { data, error, status } = await request.send({ payload: {} });
expect(data).toBeNull();
expect(status).toBe(422);
expect(error).toStrictEqual({ errors: { email: "Email is required" } });
});
it("should return success when payload is valid", async () => {
const client = new Client({ url: baseUrl });
const request = client.createRequest<{
response: { success: boolean };
payload: { email: string };
}>()({
endpoint: "/validate",
method: "POST",
});
const { data, error, status } = await request.send({ payload: { email: "test@example.com" } });
expect(error).toBeNull();
expect(status).toBe(200);
expect(data).toStrictEqual({ success: true });
});
});