diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..195a2d9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Check formatting + run: deno fmt --check + + - name: Run linter + run: deno lint + + - name: Type check + run: deno check source/mod.ts + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Run tests + run: deno task test + + - name: Run tests with coverage + run: deno task test:coverage + + publish-dry-run: + name: Publish Dry Run + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Dry run publish to JSR + run: deno publish --dry-run --allow-slow-types diff --git a/.github/workflows/update-and-deploy.yml b/.github/workflows/update-and-deploy.yml index 4f7dd31..60a5298 100644 --- a/.github/workflows/update-and-deploy.yml +++ b/.github/workflows/update-and-deploy.yml @@ -7,7 +7,7 @@ on: pull_request: branches: main paths: - - source/** + - source/** jobs: release: @@ -33,4 +33,4 @@ jobs: run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - deno run --allow-all --unstable-broadcast-channel --unstable-kv https://deno.land/x/automation_scripts@0.0.6/ci-cd/scripts/UpdateSemverDeployJsr.ts \ No newline at end of file + deno run --allow-all --unstable-broadcast-channel --unstable-kv https://deno.land/x/automation_scripts@0.0.6/ci-cd/scripts/UpdateSemverDeployJsr.ts diff --git a/deno.json b/deno.json index 5c6a6a0..084b0b0 100644 --- a/deno.json +++ b/deno.json @@ -6,6 +6,9 @@ "versionsFilePath": "./source/versions.ts" } }, + "imports": { + "@std/assert": "https://deno.land/std@0.224.0/assert/mod.ts" + }, "exports": { ".": "./source/mod.ts", "./types": "./source/types.ts", @@ -14,6 +17,12 @@ }, "lock": false, "tasks": { + "test": "deno test --allow-net source/", + "test:coverage": "deno test --allow-net --coverage=coverage source/", + "lint": "deno lint", + "fmt": "deno fmt", + "fmt:check": "deno fmt --check", + "check": "deno check source/mod.ts", "ex2": "deno run -A ./examples/ex2.ts", "ex5": "deno run -A ./examples/ex5.ts", "ex6": "deno run -A ./examples/ex5.ts", @@ -23,5 +32,14 @@ "exclude": [ "./source/versions.ts" ] + }, + "lint": { + "exclude": [ + "./examples/", + "./scripts/" + ], + "rules": { + "exclude": ["no-slow-types"] + } } -} \ No newline at end of file +} diff --git a/source/Fetch_test.ts b/source/Fetch_test.ts new file mode 100644 index 0000000..4ff0252 --- /dev/null +++ b/source/Fetch_test.ts @@ -0,0 +1,192 @@ +import { assertEquals } from "@std/assert"; +import { Fetchify } from "./Fetch.ts"; + +function mockFetch() { + const originalFetch = globalThis.fetch; + const calls: { url: string; method: string; headers?: HeadersInit }[] = []; + + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + url: input.toString(), + method: init?.method || "GET", + headers: init?.headers, + }); + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + }; + + return { + calls, + cleanup: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +const testOpts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test("Fetchify - GET request", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify(); + + try { + const response = await client.get("https://example.com/api/users"); + assertEquals(response.status, 200); + assertEquals(calls[0].method, "GET"); + assertEquals(calls[0].url, "https://example.com/api/users"); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - POST request", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify(); + + try { + const response = await client.post("https://example.com/api/users"); + assertEquals(response.status, 200); + assertEquals(calls[0].method, "POST"); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - PUT request", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify(); + + try { + const response = await client.put("https://example.com/api/users/1"); + assertEquals(response.status, 200); + assertEquals(calls[0].method, "PUT"); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - DELETE request", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify(); + + try { + const response = await client.delete("https://example.com/api/users/1"); + assertEquals(response.status, 200); + assertEquals(calls[0].method, "DELETE"); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - HEAD request", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify(); + + try { + const response = await client.head("https://example.com/api/users"); + assertEquals(response.status, 200); + assertEquals(calls[0].method, "HEAD"); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - PATCH request", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify(); + + try { + const response = await client.patch("https://example.com/api/users/1"); + assertEquals(response.status, 200); + assertEquals(calls[0].method, "PATCH"); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - uses baseURL", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify({ + baseURL: "https://api.example.com", + }); + + try { + await client.get("/users"); + assertEquals(calls[0].url, "https://api.example.com/users"); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - uses default headers", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify({ + headers: { + Authorization: "Bearer token123", + "Content-Type": "application/json", + }, + }); + + try { + await client.get("https://example.com/api"); + assertEquals( + (calls[0].headers as Record)?.["Authorization"], + "Bearer token123", + ); + } finally { + cleanup(); + } +}); + +Deno.test( + "Fetchify - combines baseURL with path correctly", + testOpts, + async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify({ + baseURL: "https://api.example.com/v1", + }); + + try { + await client.get("/users/123"); + assertEquals(calls[0].url, "https://api.example.com/v1/users/123"); + } finally { + cleanup(); + } + }, +); + +Deno.test("Fetchify - with rate limiting config", testOpts, async () => { + const { cleanup } = mockFetch(); + const client = new Fetchify({ + limiter: { rps: 5 }, + }); + + try { + const response = await client.get("https://example.com/api"); + assertEquals(response.status, 200); + } finally { + cleanup(); + } +}); + +Deno.test("Fetchify - multiple requests in sequence", testOpts, async () => { + const { calls, cleanup } = mockFetch(); + const client = new Fetchify({ + baseURL: "https://api.example.com", + }); + + try { + await client.get("/users"); + await client.post("/users"); + await client.delete("/users/1"); + + assertEquals(calls.length, 3); + assertEquals(calls[0].method, "GET"); + assertEquals(calls[1].method, "POST"); + assertEquals(calls[2].method, "DELETE"); + } finally { + cleanup(); + } +}); diff --git a/source/Limiter_test.ts b/source/Limiter_test.ts new file mode 100644 index 0000000..ad5d413 --- /dev/null +++ b/source/Limiter_test.ts @@ -0,0 +1,186 @@ +import { assertEquals } from "@std/assert"; +import { Limiter } from "./Limiter.ts"; + +function mockFetch(response: Response = new Response("", { status: 200 })) { + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(response); + return () => { + globalThis.fetch = originalFetch; + }; +} + +const testOpts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test( + "Limiter.fetch - static method makes unlimited request", + testOpts, + async () => { + const cleanup = mockFetch( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + + try { + const response = await Limiter.fetch("https://example.com/api"); + assertEquals(response.status, 200); + } finally { + cleanup(); + } + }, +); + +Deno.test( + "Limiter.fetch - static method adds query params", + testOpts, + async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + + globalThis.fetch = (input: RequestInfo | URL) => { + capturedUrl = input.toString(); + return Promise.resolve(new Response("", { status: 200 })); + }; + + try { + await Limiter.fetch("https://example.com/api", { + params: { page: 1, limit: 10 }, + }); + assertEquals(capturedUrl, "https://example.com/api?page=1&limit=10"); + } finally { + globalThis.fetch = originalFetch; + } + }, +); + +Deno.test("Limiter - instance fetch with rate limiting", testOpts, async () => { + const cleanup = mockFetch(); + const limiter = new Limiter({ rps: 5 }); + + try { + const response = await limiter.fetch("https://example.com/api"); + assertEquals(response.status, 200); + } finally { + cleanup(); + } +}); + +Deno.test( + "Limiter - unlimited option bypasses rate limiting", + testOpts, + async () => { + const cleanup = mockFetch(); + const limiter = new Limiter({ rps: 1 }); + + try { + const response = await limiter.fetch("https://example.com/api", { + unlimited: true, + }); + assertEquals(response.status, 200); + } finally { + cleanup(); + } + }, +); + +Deno.test("Limiter - adds query params to request", testOpts, async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + + globalThis.fetch = (input: RequestInfo | URL) => { + capturedUrl = input.toString(); + return Promise.resolve(new Response("", { status: 200 })); + }; + + const limiter = new Limiter({ rps: 5 }); + + try { + await limiter.fetch("https://example.com/api", { + params: { foo: "bar" }, + }); + assertEquals(capturedUrl, "https://example.com/api?foo=bar"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +Deno.test( + "Limiter - handles multiple concurrent requests", + testOpts, + async () => { + const cleanup = mockFetch(); + const limiter = new Limiter({ rps: 10 }); + + try { + const promises = [ + limiter.fetch("https://example.com/api/1"), + limiter.fetch("https://example.com/api/2"), + limiter.fetch("https://example.com/api/3"), + ]; + + const responses = await Promise.all(promises); + assertEquals(responses.length, 3); + responses.forEach((r) => assertEquals(r.status, 200)); + } finally { + cleanup(); + } + }, +); + +Deno.test("Limiter - custom status handler", testOpts, async () => { + const cleanup = mockFetch(new Response("Not Found", { status: 404 })); + let handlerCalled = false; + + const limiter = new Limiter({ + rps: 5, + status: { + 404: (response, resolve) => { + handlerCalled = true; + resolve(response); + }, + }, + }); + + try { + const response = await limiter.fetch("https://example.com/api"); + assertEquals(handlerCalled, true); + assertEquals(response.status, 404); + } finally { + cleanup(); + } +}); + +Deno.test("Limiter - retry on failure", testOpts, async () => { + let attempts = 0; + const originalFetch = globalThis.fetch; + + globalThis.fetch = () => { + attempts++; + if (attempts < 3) { + return Promise.reject(new Error("Network error")); + } + return Promise.resolve(new Response("", { status: 200 })); + }; + + const limiter = new Limiter({ rps: 5 }); + + try { + const response = await limiter.fetch("https://example.com/api", { + attempts: 3, + }); + assertEquals(response.status, 200); + assertEquals(attempts, 3); + } finally { + globalThis.fetch = originalFetch; + } +}); + +Deno.test("Limiter - global unlimited option", testOpts, async () => { + const cleanup = mockFetch(); + const limiter = new Limiter({ unlimited: true }); + + try { + const response = await limiter.fetch("https://example.com/api"); + assertEquals(response.status, 200); + } finally { + cleanup(); + } +}); diff --git a/source/fetchWithTimeout_test.ts b/source/fetchWithTimeout_test.ts new file mode 100644 index 0000000..eae7975 --- /dev/null +++ b/source/fetchWithTimeout_test.ts @@ -0,0 +1,71 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { fetchWithTimeout } from "./fetchWithTimeout.ts"; + +Deno.test("fetchWithTimeout - successful request within timeout", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = () => + Promise.resolve( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + + try { + const response = await fetchWithTimeout("https://example.com", 5000); + assertEquals(response.status, 200); + const data = await response.json(); + assertEquals(data.success, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +Deno.test("fetchWithTimeout - aborts on timeout", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = (_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + resolve(new Response("", { status: 200 })); + }, 1000); + + init?.signal?.addEventListener("abort", () => { + clearTimeout(timeoutId); + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }); + }; + + try { + await assertRejects( + () => fetchWithTimeout("https://example.com", 50), + DOMException, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +Deno.test("fetchWithTimeout - passes init options to fetch", async () => { + const originalFetch = globalThis.fetch; + let capturedInit: RequestInit | undefined; + + globalThis.fetch = (_input: RequestInfo | URL, init?: RequestInit) => { + capturedInit = init; + return Promise.resolve(new Response("", { status: 200 })); + }; + + try { + await fetchWithTimeout("https://example.com", 5000, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + assertEquals(capturedInit?.method, "POST"); + assertEquals( + (capturedInit?.headers as Record)?.["Content-Type"], + "application/json", + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/source/helpers.ts b/source/helpers.ts index 7889fc8..2042613 100644 --- a/source/helpers.ts +++ b/source/helpers.ts @@ -9,7 +9,6 @@ export const objectToQueryParams = (params: IQueryParams): string => { }; export const getUrlFromStringOrRequest = (input: FetchInput): string => { - let url = ""; if (input instanceof URL) { return input.toString(); } else if (input instanceof Request) { diff --git a/source/helpers_test.ts b/source/helpers_test.ts new file mode 100644 index 0000000..6255d20 --- /dev/null +++ b/source/helpers_test.ts @@ -0,0 +1,77 @@ +import { assertEquals } from "@std/assert"; +import { + combineURL, + getUrlFromStringOrRequest, + objectToQueryParams, +} from "./helpers.ts"; + +Deno.test("objectToQueryParams - converts object to query string", () => { + const params = { foo: "bar", baz: 123, active: true }; + const result = objectToQueryParams(params); + assertEquals(result, "foo=bar&baz=123&active=true"); +}); + +Deno.test("objectToQueryParams - encodes special characters", () => { + const params = { query: "hello world", special: "a&b=c" }; + const result = objectToQueryParams(params); + assertEquals(result, "query=hello%20world&special=a%26b%3Dc"); +}); + +Deno.test("objectToQueryParams - handles empty object", () => { + const result = objectToQueryParams({}); + assertEquals(result, ""); +}); + +Deno.test("getUrlFromStringOrRequest - returns string as is", () => { + const result = getUrlFromStringOrRequest("https://example.com/api"); + assertEquals(result, "https://example.com/api"); +}); + +Deno.test("getUrlFromStringOrRequest - extracts URL from URL object", () => { + const url = new URL("https://example.com/api"); + const result = getUrlFromStringOrRequest(url); + assertEquals(result, "https://example.com/api"); +}); + +Deno.test("getUrlFromStringOrRequest - extracts URL from Request object", () => { + const request = new Request("https://example.com/api"); + const result = getUrlFromStringOrRequest(request); + assertEquals(result, "https://example.com/api"); +}); + +Deno.test("combineURL - combines base URL and path", () => { + const result = combineURL("https://example.com", "/api/users"); + assertEquals(result.toString(), "https://example.com/api/users"); +}); + +Deno.test("combineURL - handles base URL without trailing slash", () => { + const result = combineURL("https://example.com", "api/users"); + assertEquals(result.toString(), "https://example.com/api/users"); +}); + +Deno.test("combineURL - handles base URL with trailing slash", () => { + const result = combineURL("https://example.com/", "/api/users"); + assertEquals(result.toString(), "https://example.com/api/users"); +}); + +Deno.test("combineURL - adds query parameters", () => { + const result = combineURL("https://example.com", "/api/users", { + page: 1, + limit: 10, + }); + assertEquals( + result.toString(), + "https://example.com/api/users?page=1&limit=10", + ); +}); + +Deno.test("combineURL - removes trailing slash from result", () => { + const result = combineURL("https://example.com", "/api/"); + assertEquals(result.toString(), "https://example.com/api"); +}); + +Deno.test("combineURL - works with URL objects", () => { + const baseURL = new URL("https://example.com"); + const result = combineURL(baseURL, "/api/users"); + assertEquals(result.toString(), "https://example.com/api/users"); +}); diff --git a/source/parsers.ts b/source/parsers.ts index bd83cd8..870b755 100644 --- a/source/parsers.ts +++ b/source/parsers.ts @@ -7,7 +7,7 @@ export const text = async (promise: Promise) => { return { data, response }; }; -export const json = async (promise: Promise, schema?: T) => { +export const json = async (promise: Promise, _schema?: T) => { const response = await promise; const data = await response.json() as T; diff --git a/source/parsers_test.ts b/source/parsers_test.ts new file mode 100644 index 0000000..353e635 --- /dev/null +++ b/source/parsers_test.ts @@ -0,0 +1,93 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { v, z } from "../deps.ts"; +import { json, jsonV, jsonZ, text } from "./parsers.ts"; + +Deno.test("text - parses response as text", async () => { + const mockResponse = new Response("Hello, World!", { status: 200 }); + const { data, response } = await text(Promise.resolve(mockResponse)); + + assertEquals(data, "Hello, World!"); + assertEquals(response.status, 200); +}); + +Deno.test("json - parses response as JSON", async () => { + const mockResponse = new Response(JSON.stringify({ name: "John", age: 30 }), { + status: 200, + }); + const { data, response } = await json<{ name: string; age: number }>( + Promise.resolve(mockResponse), + ); + + assertEquals(data.name, "John"); + assertEquals(data.age, 30); + assertEquals(response.status, 200); +}); + +Deno.test("json - handles arrays", async () => { + const mockResponse = new Response(JSON.stringify([1, 2, 3]), { status: 200 }); + const { data } = await json(Promise.resolve(mockResponse)); + + assertEquals(data, [1, 2, 3]); +}); + +Deno.test("jsonZ - validates with Zod schema", async () => { + const schema = z.object({ + id: z.number(), + name: z.string(), + }); + + const mockResponse = new Response(JSON.stringify({ id: 1, name: "Test" }), { + status: 200, + }); + + const { data, response } = await jsonZ(Promise.resolve(mockResponse), schema); + + assertEquals(data.id, 1); + assertEquals(data.name, "Test"); + assertEquals(response.status, 200); +}); + +Deno.test("jsonZ - throws on invalid data", async () => { + const schema = z.object({ + id: z.number(), + name: z.string(), + }); + + const mockResponse = new Response( + JSON.stringify({ id: "not-a-number", name: "Test" }), + { status: 200 }, + ); + + await assertRejects(() => jsonZ(Promise.resolve(mockResponse), schema)); +}); + +Deno.test("jsonV - validates with ValiBot schema", async () => { + const schema = v.object({ + id: v.number(), + name: v.string(), + }); + + const mockResponse = new Response(JSON.stringify({ id: 1, name: "Test" }), { + status: 200, + }); + + const { data, response } = await jsonV(Promise.resolve(mockResponse), schema); + + assertEquals(data.id, 1); + assertEquals(data.name, "Test"); + assertEquals(response.status, 200); +}); + +Deno.test("jsonV - throws on invalid data", async () => { + const schema = v.object({ + id: v.number(), + name: v.string(), + }); + + const mockResponse = new Response( + JSON.stringify({ id: "not-a-number", name: "Test" }), + { status: 200 }, + ); + + await assertRejects(() => jsonV(Promise.resolve(mockResponse), schema)); +});