From 58c8f3326e4099482af8f352b4122afa8491be58 Mon Sep 17 00:00:00 2001 From: George Alexiou Date: Thu, 12 Jun 2025 18:30:01 +0100 Subject: [PATCH] feat: add support for custom API URL in convertMarkdownToPdf and pollConversionStatus functions --- src/__tests__/convertMarkdownToPdf.test.ts | 28 ++++++++- src/__tests__/pollConversionStatus.test.ts | 67 ++++++++++++++++------ src/convertMarkdownToPdf.ts | 5 +- src/helpers.ts | 6 +- src/types.ts | 1 + 5 files changed, 82 insertions(+), 25 deletions(-) diff --git a/src/__tests__/convertMarkdownToPdf.test.ts b/src/__tests__/convertMarkdownToPdf.test.ts index c648d1b..9435442 100644 --- a/src/__tests__/convertMarkdownToPdf.test.ts +++ b/src/__tests__/convertMarkdownToPdf.test.ts @@ -103,7 +103,7 @@ 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, @@ -111,6 +111,32 @@ describe("convertMarkdownToPdf", () => { 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); diff --git a/src/__tests__/pollConversionStatus.test.ts b/src/__tests__/pollConversionStatus.test.ts index cbe214a..76f0faa 100644 --- a/src/__tests__/pollConversionStatus.test.ts +++ b/src/__tests__/pollConversionStatus.test.ts @@ -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); @@ -74,6 +74,20 @@ 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 @@ -81,7 +95,7 @@ describe("pollConversionStatus", () => { .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); @@ -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`, ); }); @@ -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); }); }); @@ -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 () => { @@ -137,7 +151,9 @@ 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 () => { @@ -145,7 +161,7 @@ describe("pollConversionStatus", () => { 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 () => { @@ -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.", + ); }); }); @@ -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 () => { @@ -175,7 +193,9 @@ 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 () => { @@ -183,7 +203,7 @@ describe("pollConversionStatus", () => { mockedAxiosIsAxiosError.mockReturnValue(false); mockedAxiosGet.mockRejectedValueOnce(customError); - await expect(pollConversionStatus(mockPath)).rejects.toThrow(customError); + await expect(pollConversionStatus(mockPath, M2PDF_API_URL)).rejects.toThrow(customError); }); }); @@ -191,7 +211,7 @@ describe("pollConversionStatus", () => { 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); }); @@ -199,7 +219,7 @@ describe("pollConversionStatus", () => { 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); }); @@ -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", () => { @@ -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); @@ -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); @@ -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); diff --git a/src/convertMarkdownToPdf.ts b/src/convertMarkdownToPdf.ts index bfefb3f..559328a 100644 --- a/src/convertMarkdownToPdf.ts +++ b/src/convertMarkdownToPdf.ts @@ -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."); @@ -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; @@ -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 }); diff --git a/src/helpers.ts b/src/helpers.ts index 7c9dca6..d905200 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -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 ( @@ -37,8 +37,8 @@ export const handlePayment = async ( await sleep(M2PDF_POLL_INTERVAL); }; -export const pollConversionStatus = async (path: string): Promise => { - const statusUrl = buildUrl(path, M2PDF_API_URL); +export const pollConversionStatus = async (path: string, apiUrl: string): Promise => { + const statusUrl = buildUrl(path, apiUrl); const pollStartTime = Date.now(); while (true) { diff --git a/src/types.ts b/src/types.ts index ae9436c..e210379 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export type ConvertToPdfParams = { title?: string; downloadPath?: string; returnBytes?: boolean; + apiUrl?: string; }; export class Markdown2PdfError extends Error {