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: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ dist
env.d.ts
next-env.d.ts
**/.vscode
.env
2 changes: 1 addition & 1 deletion packages/api/src/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down
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();
}
});
});
72 changes: 72 additions & 0 deletions packages/api/test/search.test.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
});
});
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
Loading