diff --git a/.gitignore b/.gitignore index fb51640..3142e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ dist env.d.ts next-env.d.ts **/.vscode +.env \ No newline at end of file diff --git a/packages/api/src/lib/url.ts b/packages/api/src/lib/url.ts index 50e1ddf..c73d1dd 100644 --- a/packages/api/src/lib/url.ts +++ b/packages/api/src/lib/url.ts @@ -7,7 +7,7 @@ export const removeBeginningSlash = (url: string) => { return url.startsWith("/") ? url.slice(1) : url; }; -const fieldsKey = ["wordFields", "translationFields", "fields"]; +const fieldsKey = ["word_fields", "translation_fields", "fields"]; export const paramsToString = (params?: ApiParams): string => { if (!params) return ""; diff --git a/packages/api/test/client.test.ts b/packages/api/test/client.test.ts new file mode 100644 index 0000000..0e5528c --- /dev/null +++ b/packages/api/test/client.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { QuranClient } from "../src"; +import { QuranFetcher } from "../src/sdk/fetcher"; +import { Language } from "../src/types"; + +const baseConfig = { + clientId: "client-id", + clientSecret: "client-secret", +}; + +describe("QuranClient", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("exposes a cloned configuration object with resolved defaults", () => { + const client = new QuranClient(baseConfig); + + const config = client.getConfig(); + + expect(config.contentBaseUrl).toBe("https://apis.quran.foundation"); + expect(config.authBaseUrl).toBe("https://oauth2.quran.foundation"); + expect(config.defaults?.language).toBe(Language.ARABIC); + }); + + it("merges updates and forwards the new config to the fetcher", () => { + const client = new QuranClient({ + ...baseConfig, + defaults: { + perPage: 10, + }, + }); + + const updateSpy = vi.spyOn(QuranFetcher.prototype, "updateConfig"); + + client.updateConfig({ + contentBaseUrl: "https://custom.example.com", + defaults: { + language: Language.ENGLISH, + }, + }); + + const updatedConfig = client.getConfig(); + + expect(updatedConfig.contentBaseUrl).toBe( + "https://custom.example.com", + ); + expect(updatedConfig.defaults?.language).toBe(Language.ENGLISH); + expect(updatedConfig.defaults?.perPage).toBe(10); + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + contentBaseUrl: "https://custom.example.com", + defaults: expect.objectContaining({ + language: Language.ENGLISH, + perPage: 10, + }), + }), + ); + }); + + it("delegates token clearing to the fetcher", () => { + const client = new QuranClient(baseConfig); + + const clearSpy = vi.spyOn( + QuranFetcher.prototype, + "clearCachedToken", + ); + + client.clearCachedToken(); + + expect(clearSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/test/fetcher.test.ts b/packages/api/test/fetcher.test.ts new file mode 100644 index 0000000..cda05d9 --- /dev/null +++ b/packages/api/test/fetcher.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; + +import { QuranFetcher } from "../src/sdk/fetcher"; +import { Language } from "../src/types"; + +const baseConfig = { + clientId: "client-id", + clientSecret: "client-secret", + contentBaseUrl: "https://apis.quran.foundation", + authBaseUrl: "https://oauth2.quran.foundation", + defaults: { + language: Language.ENGLISH, + perPage: 25, + }, +} as const; + +type MockResponse = { + ok: boolean; + status: number; + statusText: string; + json: () => Promise; +}; + +const createResponse = ( + data: T, + overrides: Partial = {}, +): MockResponse => + ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => data, + ...overrides, + }); + +describe("QuranFetcher", () => { + it("requests an access token once and reuses it for subsequent API calls", async () => { + const fetchMock = vi + .fn<[string, RequestInit?], Promise>() + .mockResolvedValueOnce( + createResponse({ + access_token: "token-123", + token_type: "bearer", + expires_in: 3600, + scope: "content", + }), + ) + .mockResolvedValueOnce( + createResponse({ + sample_value: 42, + }), + ) + .mockResolvedValueOnce( + createResponse({ + sample_value: 84, + }), + ); + + const fetcher = new QuranFetcher({ + ...baseConfig, + fetch: fetchMock, + }); + + const firstResult = await fetcher.fetch<{ sampleValue: number }>( + "/content/api/v4/example", + { + page: 2, + words: true, + }, + ); + + const secondResult = await fetcher.fetch<{ sampleValue: number }>( + "/content/api/v4/example", + { page: 3 }, + ); + + expect(firstResult.sampleValue).toBe(42); + expect(secondResult.sampleValue).toBe(84); + + expect(fetchMock).toHaveBeenCalledTimes(3); + + const [tokenUrl, tokenOptions] = fetchMock.mock.calls[0]; + expect(tokenUrl).toBe(`${baseConfig.authBaseUrl}/oauth2/token`); + expect(tokenOptions?.method).toBe("POST"); + expect(tokenOptions?.headers).toMatchObject({ + Authorization: expect.stringContaining("Basic "), + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }); + + const tokenBody = new URLSearchParams( + tokenOptions?.body as string, + ); + expect(tokenBody.get("grant_type")).toBe("client_credentials"); + expect(tokenBody.get("scope")).toBe("content"); + + const [firstDataUrl, firstDataOptions] = fetchMock.mock.calls[1]; + const firstUrl = new URL(firstDataUrl as string); + expect(firstUrl.origin + firstUrl.pathname).toBe( + `${baseConfig.contentBaseUrl}/content/api/v4/example`, + ); + expect(firstUrl.searchParams.get("language")).toBe(Language.ENGLISH); + expect(firstUrl.searchParams.get("per_page")).toBe("25"); + expect(firstUrl.searchParams.get("page")).toBe("2"); + expect(firstUrl.searchParams.get("words")).toBe("true"); + + const [secondDataUrl, secondDataOptions] = fetchMock.mock.calls[2]; + const secondUrl = new URL(secondDataUrl as string); + expect(secondUrl.searchParams.get("page")).toBe("3"); + + expect(firstDataOptions?.headers).toMatchObject({ + "x-auth-token": "token-123", + "x-client-id": baseConfig.clientId, + "Content-Type": "application/json", + }); + + expect(secondDataOptions?.headers).toMatchObject({ + "x-auth-token": "token-123", + "x-client-id": baseConfig.clientId, + }); + }); + + it("throws an error when the API response is not ok", async () => { + const fetchMock = vi + .fn<[string, RequestInit?], Promise>() + .mockResolvedValueOnce( + createResponse({ + access_token: "token-456", + token_type: "bearer", + expires_in: 3600, + scope: "content", + }), + ) + .mockResolvedValueOnce( + createResponse( + { error: "server failure" }, + { ok: false, status: 500, statusText: "Server Error" }, + ), + ); + + const fetcher = new QuranFetcher({ + ...baseConfig, + fetch: fetchMock, + }); + + await expect( + fetcher.fetch("/content/api/v4/example"), + ).rejects.toThrowError("500 Server Error"); + }); +}); diff --git a/packages/api/test/retry.test.ts b/packages/api/test/retry.test.ts new file mode 100644 index 0000000..e8be21f --- /dev/null +++ b/packages/api/test/retry.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { retry } from "../src/lib/retry"; + +describe("retry helper", () => { + it("retries until the wrapped promise resolves", async () => { + vi.useFakeTimers(); + + try { + const task = vi + .fn<[], Promise>() + .mockRejectedValueOnce(new Error("first failure")) + .mockResolvedValueOnce("success"); + + const promise = retry(task, { retries: 1 }); + const expectation = expect(promise).resolves.toBe("success"); + + await vi.runAllTimersAsync(); + + await expectation; + expect(task).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("propagates the last error once retries are exhausted", async () => { + vi.useFakeTimers(); + + try { + const error = new Error("always failing"); + const task = vi.fn<[], Promise>().mockRejectedValue(error); + + const promise = retry(task, { retries: 2 }); + const expectation = expect(promise).rejects.toBe(error); + + await vi.runAllTimersAsync(); + + await expectation; + expect(task).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/api/test/search.test.ts b/packages/api/test/search.test.ts new file mode 100644 index 0000000..4bf841f --- /dev/null +++ b/packages/api/test/search.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; + +import { QuranSearch } from "../src/sdk/search"; +import type { QuranFetcher } from "../src/sdk/fetcher"; +import { Language } from "../src/types"; + +describe("Search API", () => { + it("uses the default page size when none is provided", async () => { + const fakeFetcher = { + fetch: vi.fn().mockResolvedValue({ + search: { + query: "mercy", + totalResults: 1, + currentPage: 1, + totalPages: 1, + results: [], + }, + }), + } as unknown as QuranFetcher; + + const searchApi = new QuranSearch(fakeFetcher); + + const result = await searchApi.search("mercy"); + + expect(fakeFetcher.fetch).toHaveBeenCalledWith( + "/content/api/v4/search", + { + q: "mercy", + size: 30, + }, + ); + expect(result).toEqual({ + query: "mercy", + totalResults: 1, + currentPage: 1, + totalPages: 1, + results: [], + }); + }); + + it("merges custom options with the query", async () => { + const fakeFetcher = { + fetch: vi.fn().mockResolvedValue({ + search: { + query: "mercy", + totalResults: 2, + currentPage: 2, + totalPages: 5, + results: [], + }, + }), + } as unknown as QuranFetcher; + + const searchApi = new QuranSearch(fakeFetcher); + + await searchApi.search("mercy", { + size: 10, + page: 2, + language: Language.ENGLISH, + }); + + expect(fakeFetcher.fetch).toHaveBeenLastCalledWith( + "/content/api/v4/search", + { + q: "mercy", + size: 10, + page: 2, + language: Language.ENGLISH, + }, + ); + }); +}); diff --git a/packages/api/test/setup.ts b/packages/api/test/setup.ts index c9d4523..8309e61 100644 --- a/packages/api/test/setup.ts +++ b/packages/api/test/setup.ts @@ -3,6 +3,11 @@ import { afterAll, afterEach, beforeAll, beforeEach } from "vitest"; import { server } from "../mocks/server"; +if (typeof globalThis.btoa !== "function") { + globalThis.btoa = (value: string) => + Buffer.from(value, "utf-8").toString("base64"); +} + // Establish API mocking before all tests. beforeAll(() => { server.listen(); diff --git a/packages/api/test/url.test.ts b/packages/api/test/url.test.ts new file mode 100644 index 0000000..b7e8b17 --- /dev/null +++ b/packages/api/test/url.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { paramsToString, removeBeginningSlash } from "../src/lib/url"; +import { Language } from "../src/types"; + +describe("URL helpers", () => { + it("removes a leading slash from paths", () => { + expect(removeBeginningSlash("/content/api")).toBe("content/api"); + expect(removeBeginningSlash("content/api")).toBe("content/api"); + }); + + it("returns an empty string when no params are supplied", () => { + expect(paramsToString()).toBe(""); + expect(paramsToString({})).toBe(""); + }); + + it("serialises complex query parameters correctly", () => { + const query = paramsToString({ + language: Language.ENGLISH, + page: 2, + perPage: 25, + words: true, + translations: [1, 2, 3], + fields: { + textUthmani: true, + codeV1: false, + }, + wordFields: { + textUthmani: true, + codeV2: true, + }, + translationFields: { + verseKey: true, + languageName: false, + }, + }); + + expect(query.startsWith("?")).toBe(true); + + const searchParams = new URLSearchParams(query.slice(1)); + + expect(searchParams.get("language")).toBe(Language.ENGLISH); + expect(searchParams.get("page")).toBe("2"); + expect(searchParams.get("per_page")).toBe("25"); + expect(searchParams.get("words")).toBe("true"); + expect(searchParams.get("translations")).toBe("1,2,3"); + expect(searchParams.get("fields")).toBe("text_uthmani"); + expect(searchParams.get("word_fields")).toBe("text_uthmani,code_v2"); + expect(searchParams.get("translation_fields")).toBe("verse_key"); + }); +}); diff --git a/tooling/live-smoke.mjs b/tooling/live-smoke.mjs new file mode 100644 index 0000000..9bedbb7 --- /dev/null +++ b/tooling/live-smoke.mjs @@ -0,0 +1,221 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const loadEnv = () => { + const envPath = resolve(process.cwd(), ".env"); + if (!existsSync(envPath)) return; + + const lines = readFileSync(envPath, "utf-8").split(/\r?\n/); + for (const line of lines) { + if (!line || line.startsWith("#")) continue; + const [key, ...rest] = line.split("="); + if (!key) continue; + if (process.env[key]) continue; + const value = rest.join("="); + process.env[key] = value; + } +}; + +loadEnv(); + +const clientId = process.env.QF_CLIENT_ID; +const clientSecret = process.env.QF_CLIENT_SECRET; + +if (!clientId || !clientSecret) { + console.error("Missing QF_CLIENT_ID or QF_CLIENT_SECRET env vars."); + process.exitCode = 1; + process.exit(); +} + +const fetchJson = async (url, options) => { + const response = await fetch(url, options); + const text = await response.text(); + let parsed; + try { + parsed = text ? JSON.parse(text) : {}; + } catch (error) { + throw new Error( + `Failed to parse JSON from ${url}: ${error.message}\nPayload: ${text}`, + ); + } + return { response, json: parsed, raw: text }; +}; + +const requestToken = async () => { + const tokenBody = new URLSearchParams({ + grant_type: "client_credentials", + scope: "content", + }); + + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`, "utf-8").toString( + "base64", + ); + + const { response, json } = await fetchJson( + "https://oauth2.quran.foundation/oauth2/token", + { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth}`, + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: tokenBody.toString(), + }, + ); + + if (!response.ok) { + throw new Error( + `Token request failed with ${response.status} ${response.statusText}`, + ); + } + + if (!json?.access_token) { + throw new Error("Token response missing access_token"); + } + + return json.access_token; +}; + +const run = async () => { + const accessToken = await requestToken(); + + const baseHeaders = { + "x-auth-token": accessToken, + "x-client-id": clientId, + "Content-Type": "application/json", + }; + + const endpoints = [ + { + name: "Chapters list", + url: "https://apis.quran.foundation/content/api/v4/chapters?language=en", + evaluate: (json) => { + const chapters = json?.chapters ?? []; + if (Array.isArray(chapters) && chapters.length > 0) { + const first = chapters[0]; + return { + ok: true, + detail: `received ${chapters.length} chapters (first: ${first?.name_simple ?? "unknown"})`, + }; + } + return { + ok: false, + detail: "no chapters array in response", + }; + }, + }, + { + name: "Single chapter", + url: "https://apis.quran.foundation/content/api/v4/chapters/1?language=en", + evaluate: (json) => { + const chapter = json?.chapter; + if (chapter?.id === 1) { + return { + ok: true, + detail: `chapter 1 retrieved (${chapter.name_simple ?? "unknown"})`, + }; + } + return { ok: false, detail: "chapter payload missing or incorrect" }; + }, + }, + { + name: "Chapter verses", + url: "https://apis.quran.foundation/content/api/v4/verses/by_chapter/1?language=en&per_page=5", + evaluate: (json) => { + const verses = json?.verses ?? []; + if (Array.isArray(verses) && verses.length > 0) { + return { + ok: true, + detail: `retrieved ${verses.length} verses (sample key: ${verses[0]?.verse_key ?? "n/a"})`, + }; + } + return { ok: false, detail: "no verses returned" }; + }, + }, + { + name: "Recitations", + url: "https://apis.quran.foundation/content/api/v4/resources/recitations?language=en", + evaluate: (json) => { + const recitations = json?.recitations ?? []; + if (Array.isArray(recitations) && recitations.length > 0) { + return { + ok: true, + detail: `retrieved ${recitations.length} recitations (first: ${recitations[0]?.reciter_name ?? "unknown"})`, + }; + } + return { ok: false, detail: "no recitations returned" }; + }, + }, + { + name: "Search", + url: "https://apis.quran.foundation/content/api/v4/search?q=mercy&language=en&size=3", + evaluate: (json) => { + const search = json?.search; + if (search?.results && search.results.length >= 0) { + return { + ok: true, + detail: `search returned ${search.totalResults ?? 0} total results (page ${search.currentPage ?? "?"})`, + }; + } + return { ok: false, detail: "search payload missing" }; + }, + }, + { + name: "Audio by chapter", + url: "https://apis.quran.foundation/content/api/v4/recitations/1/by_chapter/1?language=en", + evaluate: (json) => { + const audioFiles = json?.audio_files ?? json?.audioFiles ?? []; + if (Array.isArray(audioFiles) && audioFiles.length > 0) { + return { + ok: true, + detail: `retrieved ${audioFiles.length} audio files (first id: ${audioFiles[0]?.id ?? "n/a"})`, + }; + } + return { ok: false, detail: "no audio files returned" }; + }, + }, + ]; + + let failures = 0; + + for (const endpoint of endpoints) { + try { + const { response, json, raw } = await fetchJson(endpoint.url, { + headers: baseHeaders, + }); + + if (!response.ok) { + failures++; + const bodySnippet = raw?.slice(0, 200) ?? ""; + console.error( + `❌ ${endpoint.name}: ${response.status} ${response.statusText} - ${bodySnippet}`, + ); + continue; + } + + const result = endpoint.evaluate(json); + if (result.ok) { + console.log(`✅ ${endpoint.name}: ${result.detail}`); + } else { + failures++; + console.error(`❌ ${endpoint.name}: ${result.detail}`); + } + } catch (error) { + failures++; + console.error(`❌ ${endpoint.name}: ${(error)?.message ?? error}`); + } + } + + if (failures > 0) { + console.error(`Smoke test finished with ${failures} failure(s).`); + process.exitCode = 1; + } else { + console.log("Smoke test finished successfully."); + } +}; + +run().catch((error) => { + console.error(`Smoke test crashed: ${(error)?.message ?? error}`); + process.exitCode = 1; +});