Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ dist
env.d.ts
next-env.d.ts
**/.vscode

.env
75 changes: 75 additions & 0 deletions packages/api/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
150 changes: 150 additions & 0 deletions packages/api/test/fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
};

const createResponse = <T>(
data: T,
overrides: Partial<MockResponse> = {},
): 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<MockResponse>>()
.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<MockResponse>>()
.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");
});
});
45 changes: 45 additions & 0 deletions packages/api/test/retry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>>()
.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<never>>().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();
}
});
});
2 changes: 1 addition & 1 deletion packages/api/test/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe("Search API", () => {

expect(response.pagination.currentPage).toBe(1);
expect(response.pagination.nextPage).toBeNull();
expect(response.result?.navigation[0].resultType).toBe("surah");
expect(response.result?.navigation[0]?.resultType).toBe("surah");
});

it("sends query, mode, and default size", async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/api/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
51 changes: 51 additions & 0 deletions packages/api/test/url.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Comment on lines +17 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

LGTM! Comprehensive validation of the parameter serialization fix.

This test effectively validates the core fix described in the PR:

  • Decamelization works correctly (perPage → per_page, wordFields → word_fields)
  • Fields objects are properly filtered (only true values included)
  • All parameter types serialize correctly (strings, numbers, booleans, arrays, objects)

The use of URLSearchParams for validation mirrors the implementation approach, which strengthens the test.

Optional: Consider adding edge case tests to further strengthen coverage:

Additional test cases to consider:

  • Empty array parameter: translations: []
  • Fields object with all false values: fields: { textUthmani: false, codeV1: false }
  • Special characters in string values that require URL encoding

Apply this diff to add an edge case test:

+  it("handles edge cases gracefully", () => {
+    const query = paramsToString({
+      translations: [],
+      fields: { textUthmani: false, codeV1: false },
+    });
+
+    const searchParams = new URLSearchParams(query.slice(1));
+    expect(searchParams.get("translations")).toBe("");
+    expect(searchParams.has("fields")).toBe(false);
+  });
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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");
});
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");
});
it("handles edge cases gracefully", () => {
const query = paramsToString({
translations: [],
fields: { textUthmani: false, codeV1: false },
});
const searchParams = new URLSearchParams(query.slice(1));
expect(searchParams.get("translations")).toBe("");
expect(searchParams.has("fields")).toBe(false);
});
🤖 Prompt for AI Agents
In packages/api/test/url.test.ts around lines 17 to 50, add an additional test
case that exercises edge conditions: pass translations: [] to ensure empty
arrays are omitted or serialized as expected, pass fields and wordFields objects
with all false values to confirm they produce no entries, and include a string
parameter containing special characters (e.g., spaces, & and =) to verify proper
URL encoding; construct the query with paramsToString, assert it starts with
"?", parse with URLSearchParams(query.slice(1)), and assert the translations key
is absent or empty per implementation, that fields/word_fields are null/empty,
and that the special-character parameter value equals the correctly encoded
original string when retrieved via URLSearchParams.

});
Loading