Skip to content
Merged
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
28 changes: 27 additions & 1 deletion src/__tests__/convertMarkdownToPdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,40 @@ describe("convertMarkdownToPdf", () => {

expect(mockedCreateConversionPayload).toHaveBeenCalledWith(mockMarkdown, "Custom Title", "January 1, 2025");
expect(mockedAxiosPost).toHaveBeenCalledWith(`${M2PDF_API_URL}/v1/markdown`, mockPayload);
expect(mockedPollConversionStatus).toHaveBeenCalledWith(mockPath);
expect(mockedPollConversionStatus).toHaveBeenCalledWith(mockPath, M2PDF_API_URL);
expect(mockedDownloadPdf).toHaveBeenCalledWith(mockDownloadUrl, {
downloadPath: undefined,
returnBytes: true,
});
expect(result).toBe(mockPdfBuffer);
});

it("should use custom apiUrl when provided", async () => {
const customApiUrl = "https://custom.api.example.com";
mockedAxiosPost.mockResolvedValueOnce(mockSuccessResponse);

const result = await convertMarkdownToPdf(mockMarkdown, {
onPaymentRequest: mockPaymentHandler,
apiUrl: customApiUrl,
});

expect(mockedAxiosPost).toHaveBeenCalledWith(`${customApiUrl}/v1/markdown`, mockPayload);
expect(mockedPollConversionStatus).toHaveBeenCalledWith(mockPath, customApiUrl);
expect(result).toBe(mockPdfBuffer);
});

it("should use default apiUrl when not provided", async () => {
mockedAxiosPost.mockResolvedValueOnce(mockSuccessResponse);

const result = await convertMarkdownToPdf(mockMarkdown, {
onPaymentRequest: mockPaymentHandler,
});

expect(mockedAxiosPost).toHaveBeenCalledWith(`${M2PDF_API_URL}/v1/markdown`, mockPayload);
expect(mockedPollConversionStatus).toHaveBeenCalledWith(mockPath, M2PDF_API_URL);
expect(result).toBe(mockPdfBuffer);
});

it("should use default title when not provided", async () => {
mockedAxiosPost.mockResolvedValueOnce(mockSuccessResponse);

Expand Down
67 changes: 48 additions & 19 deletions src/__tests__/pollConversionStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe("pollConversionStatus", () => {
.mockResolvedValueOnce(mockDoneResponse) // Status check
.mockResolvedValueOnce(mockMetadataResponse); // Metadata fetch

const result = await pollConversionStatus(mockPath);
const result = await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedBuildUrl).toHaveBeenCalledWith(mockPath, M2PDF_API_URL);
expect(mockedWithTimeout).toHaveBeenCalledWith(expect.any(Promise), M2PDF_TIMEOUTS.REQUEST);
Expand All @@ -74,14 +74,28 @@ describe("pollConversionStatus", () => {
expect(mockedSleep).not.toHaveBeenCalled();
});

it("should use custom apiUrl when provided", async () => {
const customApiUrl = "https://custom.api.example.com";
const customStatusUrl = `${customApiUrl}/api/conversion/status/test-123`;
mockedBuildUrl.mockReturnValue(customStatusUrl);

mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockResolvedValueOnce(mockMetadataResponse);

const result = await pollConversionStatus(mockPath, customApiUrl);

expect(mockedBuildUrl).toHaveBeenCalledWith(mockPath, customApiUrl);
expect(mockedAxiosGet).toHaveBeenCalledWith(customStatusUrl);
expect(result).toBe(mockDownloadUrl);
});

it("should poll multiple times until conversion is done", async () => {
mockedAxiosGet
.mockResolvedValueOnce(mockPendingResponse) // First poll - pending
.mockResolvedValueOnce(mockPendingResponse) // Second poll - pending
.mockResolvedValueOnce(mockDoneResponse) // Third poll - done
.mockResolvedValueOnce(mockMetadataResponse); // Metadata fetch

const result = await pollConversionStatus(mockPath);
const result = await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedAxiosGet).toHaveBeenCalledTimes(4);
expect(mockedSleep).toHaveBeenCalledWith(M2PDF_POLL_INTERVAL);
Expand All @@ -99,7 +113,7 @@ describe("pollConversionStatus", () => {

mockedAxiosGet.mockResolvedValue(mockPendingResponse);

await expect(pollConversionStatus(mockPath)).rejects.toThrow(
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow(
`Status polling timed out after ${M2PDF_TIMEOUTS.POLLING}ms`,
);
});
Expand All @@ -116,7 +130,7 @@ describe("pollConversionStatus", () => {
.mockResolvedValueOnce(mockDoneResponse) // Second poll - done
.mockResolvedValueOnce(mockMetadataResponse); // Metadata

const result = await pollConversionStatus(mockPath);
const result = await pollConversionStatus(mockPath, M2PDF_API_URL);
expect(result).toBe(mockDownloadUrl);
});
});
Expand All @@ -126,7 +140,7 @@ describe("pollConversionStatus", () => {
const errorResponse = { ...mockPendingResponse, status: 500 };
mockedAxiosGet.mockResolvedValueOnce(errorResponse);

await expect(pollConversionStatus(mockPath)).rejects.toThrow("Polling error");
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow("Polling error");
});

it("should throw error when done but no path provided", async () => {
Expand All @@ -137,15 +151,17 @@ describe("pollConversionStatus", () => {

mockedAxiosGet.mockResolvedValueOnce(responseWithoutPath);

await expect(pollConversionStatus(mockPath)).rejects.toThrow("Missing 'path' field pointing to final metadata.");
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow(
"Missing 'path' field pointing to final metadata.",
);
});

it("should throw error on metadata fetch failure", async () => {
const metadataErrorResponse = { ...mockMetadataResponse, status: 404 };

mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockResolvedValueOnce(metadataErrorResponse);

await expect(pollConversionStatus(mockPath)).rejects.toThrow("Failed to retrieve metadata.");
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow("Failed to retrieve metadata.");
});

it("should throw error when metadata response missing URL", async () => {
Expand All @@ -156,7 +172,9 @@ describe("pollConversionStatus", () => {

mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockResolvedValueOnce(metadataWithoutUrl);

await expect(pollConversionStatus(mockPath)).rejects.toThrow("Missing final download URL in metadata response.");
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow(
"Missing final download URL in metadata response.",
);
});
});

Expand All @@ -166,7 +184,7 @@ describe("pollConversionStatus", () => {
mockedAxiosIsAxiosError.mockReturnValue(true);
mockedAxiosGet.mockRejectedValueOnce(networkError);

await expect(pollConversionStatus(mockPath)).rejects.toThrow("Polling failed: Network error");
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow("Polling failed: Network error");
});

it("should throw Markdown2PdfError on axios network error during metadata fetch", async () => {
Expand All @@ -175,31 +193,33 @@ describe("pollConversionStatus", () => {

mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockRejectedValueOnce(networkError);

await expect(pollConversionStatus(mockPath)).rejects.toThrow("Polling failed: Metadata fetch failed");
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow(
"Polling failed: Metadata fetch failed",
);
});

it("should re-throw non-axios errors", async () => {
const customError = new Markdown2PdfError("Custom error");
mockedAxiosIsAxiosError.mockReturnValue(false);
mockedAxiosGet.mockRejectedValueOnce(customError);

await expect(pollConversionStatus(mockPath)).rejects.toThrow(customError);
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow(customError);
});
});

describe("timeout integration", () => {
it("should use correct timeout for status requests", async () => {
mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockResolvedValueOnce(mockMetadataResponse);

await pollConversionStatus(mockPath);
await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedWithTimeout).toHaveBeenCalledWith(expect.any(Promise), M2PDF_TIMEOUTS.REQUEST);
});

it("should use correct timeout for metadata requests", async () => {
mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockResolvedValueOnce(mockMetadataResponse);

await pollConversionStatus(mockPath);
await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedWithTimeout).toHaveBeenCalledWith(expect.any(Promise), M2PDF_TIMEOUTS.METADATA);
});
Expand All @@ -208,18 +228,27 @@ describe("pollConversionStatus", () => {
const timeoutError = new Error("Request timeout");
mockedWithTimeout.mockRejectedValueOnce(timeoutError);

await expect(pollConversionStatus(mockPath)).rejects.toThrow(timeoutError);
await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow(timeoutError);
});
});

describe("URL building", () => {
it("should build status URL correctly", async () => {
it("should build status URL correctly with default apiUrl", async () => {
mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockResolvedValueOnce(mockMetadataResponse);

await pollConversionStatus(mockPath);
await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedBuildUrl).toHaveBeenCalledWith(mockPath, M2PDF_API_URL);
});

it("should build status URL correctly with custom apiUrl", async () => {
const customApiUrl = "https://custom.api.example.com";
mockedAxiosGet.mockResolvedValueOnce(mockDoneResponse).mockResolvedValueOnce(mockMetadataResponse);

await pollConversionStatus(mockPath, customApiUrl);

expect(mockedBuildUrl).toHaveBeenCalledWith(mockPath, customApiUrl);
});
});

describe("different status values", () => {
Expand All @@ -229,7 +258,7 @@ describe("pollConversionStatus", () => {
.mockResolvedValueOnce(mockDoneResponse)
.mockResolvedValueOnce(mockMetadataResponse);

const result = await pollConversionStatus(mockPath);
const result = await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedSleep).toHaveBeenCalledTimes(1);
expect(result).toBe(mockDownloadUrl);
Expand All @@ -241,7 +270,7 @@ describe("pollConversionStatus", () => {
.mockResolvedValueOnce(mockDoneResponse)
.mockResolvedValueOnce(mockMetadataResponse);

const result = await pollConversionStatus(mockPath);
const result = await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedSleep).toHaveBeenCalledTimes(1);
expect(result).toBe(mockDownloadUrl);
Expand All @@ -253,7 +282,7 @@ describe("pollConversionStatus", () => {
.mockResolvedValueOnce(mockDoneResponse)
.mockResolvedValueOnce(mockMetadataResponse);

const result = await pollConversionStatus(mockPath);
const result = await pollConversionStatus(mockPath, M2PDF_API_URL);

expect(mockedSleep).toHaveBeenCalledTimes(1);
expect(result).toBe(mockDownloadUrl);
Expand Down
5 changes: 3 additions & 2 deletions src/convertMarkdownToPdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export async function convertMarkdownToPdf(
title = "Markdown2PDF.ai converted document",
downloadPath,
returnBytes = false,
apiUrl = M2PDF_API_URL,
} = options;

if (!onPaymentRequest) throw new Markdown2PdfError("Payment required but no handler provided.");
Expand All @@ -40,7 +41,7 @@ export async function convertMarkdownToPdf(
if (Date.now() - startTime > M2PDF_TIMEOUTS.POLLING)
throw new Markdown2PdfError(`Conversion timed out after ${M2PDF_TIMEOUTS.POLLING}ms`);

const response = await withTimeout(axios.post(`${M2PDF_API_URL}/v1/markdown`, payload), M2PDF_TIMEOUTS.REQUEST);
const response = await withTimeout(axios.post(`${apiUrl}/v1/markdown`, payload), M2PDF_TIMEOUTS.REQUEST);

if (response.status === 402) {
const l402Offer = response.data;
Expand Down Expand Up @@ -90,7 +91,7 @@ export async function convertMarkdownToPdf(
}

// Poll for conversion status and get final URL
const finalDownloadUrl = await pollConversionStatus(path);
const finalDownloadUrl = await pollConversionStatus(path, apiUrl);

// Download and return the PDF
return downloadPdf(finalDownloadUrl, { downloadPath, returnBytes });
Expand Down
6 changes: 3 additions & 3 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createWriteStream } from "fs";
import { pipeline } from "stream/promises";
import { Markdown2PdfError } from "./errors.js";
import { OfferDetails } from "./types.js";
import { M2PDF_API_URL, M2PDF_POLL_INTERVAL, M2PDF_TIMEOUTS } from "./constants.js";
import { M2PDF_POLL_INTERVAL, M2PDF_TIMEOUTS } from "./constants.js";
import { sleep, buildUrl, withTimeout } from "./utils.js";

export const handlePayment = async (
Expand Down Expand Up @@ -37,8 +37,8 @@ export const handlePayment = async (
await sleep(M2PDF_POLL_INTERVAL);
};

export const pollConversionStatus = async (path: string): Promise<string> => {
const statusUrl = buildUrl(path, M2PDF_API_URL);
export const pollConversionStatus = async (path: string, apiUrl: string): Promise<string> => {
const statusUrl = buildUrl(path, apiUrl);
const pollStartTime = Date.now();

while (true) {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type ConvertToPdfParams = {
title?: string;
downloadPath?: string;
returnBytes?: boolean;
apiUrl?: string;
};

export class Markdown2PdfError extends Error {
Expand Down