diff --git a/package.json b/package.json index f2612cb..bcdc4b4 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "engines": { "node": ">=18.0.0" }, - "packageManager": "pnpm@10.22.0", + "packageManager": "pnpm@10.23.0", "publishConfig": { "access": "public" }, diff --git a/src/client/base.ts b/src/client/base.ts index b48afe3..ee8bf21 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -58,7 +58,7 @@ export class BaseIterableClient { }; this.client = axios.create({ - baseURL: clientConfig.baseUrl || "https://api.iterable.com", + baseURL: clientConfig.baseUrl, headers: { ...defaultHeaders, ...(clientConfig.customHeaders || {}), @@ -84,7 +84,12 @@ export class BaseIterableClient { if (clientConfig.debug) { const sanitizeHeaders = (headers: any) => { if (!headers) return undefined; - const sensitive = ["api-key", "authorization", "cookie", "set-cookie"]; + const sensitive = [ + "api-key", + "authorization", + "cookie", + "set-cookie", + ]; const sanitized = { ...headers }; Object.keys(sanitized).forEach((key) => { if (sensitive.includes(key.toLowerCase())) { diff --git a/src/client/index.ts b/src/client/index.ts index 14fcd66..1ad61da 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,3 +1,6 @@ +import type { AxiosInstance } from "axios"; + +import type { IterableConfig } from "../types/common.js"; import { BaseIterableClient } from "./base.js"; import { Campaigns } from "./campaigns.js"; import { Catalogs } from "./catalogs.js"; @@ -62,4 +65,14 @@ export class IterableClient extends compose( Templates, Users, Webhooks -) {} +) { + /** + * Create a new Iterable API client + * + * @param config - Optional configuration object. If not provided, will use environment variables + * @param injectedClient - Optional pre-configured Axios instance for testing + */ + constructor(config?: IterableConfig, injectedClient?: AxiosInstance) { + super(config, injectedClient); + } +} diff --git a/src/types/common.ts b/src/types/common.ts index 762b3f5..8e49e14 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -107,7 +107,7 @@ export type IterableErrorResponse = z.infer; export const IterableConfigSchema = z.object({ apiKey: z.string(), - baseUrl: z.string().optional(), + baseUrl: z.url(), timeout: z.number().optional(), debug: z.boolean().optional(), debugVerbose: z.boolean().optional(), diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts new file mode 100644 index 0000000..3fc2b01 --- /dev/null +++ b/tests/unit/client.test.ts @@ -0,0 +1,104 @@ +import { IterableClient } from "../../src/client/index.js"; + +describe("IterableClient", () => { + describe("Constructor with baseUrl", () => { + it("should use US endpoint when explicitly provided", () => { + const client = new IterableClient({ + apiKey: "a1b2c3d4e5f6789012345678901234ab", + baseUrl: "https://api.iterable.com", + }); + + expect(client).toBeDefined(); + expect(client.client.defaults.baseURL).toBe("https://api.iterable.com"); + }); + + it("should use EU endpoint when explicitly provided", () => { + const client = new IterableClient({ + apiKey: "a1b2c3d4e5f6789012345678901234ab", + baseUrl: "https://api.eu.iterable.com", + }); + + expect(client).toBeDefined(); + expect(client.client.defaults.baseURL).toBe( + "https://api.eu.iterable.com" + ); + }); + + it("should use custom endpoint when provided", () => { + const client = new IterableClient({ + apiKey: "a1b2c3d4e5f6789012345678901234ab", + baseUrl: "https://custom.api.example.com", + }); + + expect(client).toBeDefined(); + expect(client.client.defaults.baseURL).toBe( + "https://custom.api.example.com" + ); + }); + + it("should accept all valid IterableConfig properties", () => { + const client = new IterableClient({ + apiKey: "a1b2c3d4e5f6789012345678901234ab", + baseUrl: "https://api.iterable.com", + timeout: 45000, + debug: false, + debugVerbose: true, + customHeaders: { + "X-Custom-1": "value1", + "X-Custom-2": "value2", + }, + }); + + expect(client).toBeDefined(); + + // Verify axios configuration + expect(client.client.defaults.baseURL).toBe("https://api.iterable.com"); + expect(client.client.defaults.timeout).toBe(45000); + + // Verify API key is set in headers + expect(client.client.defaults.headers["Api-Key"]).toBe( + "a1b2c3d4e5f6789012345678901234ab" + ); + + // Verify custom headers are merged into defaults + expect(client.client.defaults.headers["X-Custom-1"]).toBe("value1"); + expect(client.client.defaults.headers["X-Custom-2"]).toBe("value2"); + + // Verify standard headers are still present + expect(client.client.defaults.headers["Content-Type"]).toBe( + "application/json" + ); + expect(client.client.defaults.headers["User-Agent"]).toContain( + "iterable-api" + ); + }); + }); + + describe("Constructor with Injected Client", () => { + it("should use the injected axios instance ignoring baseUrl config", () => { + const mockAxios = { + defaults: { + // note this is the axios instance's baseURL, not the IterableConfig's baseUrl + baseURL: "https://mock.example.com", + }, + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + } as any; + + const client = new IterableClient( + { + apiKey: "a1b2c3d4e5f6789012345678901234ab", + baseUrl: "https://api.iterable.com", + }, + mockAxios + ); + + expect(client).toBeDefined(); + expect(client.client).toBe(mockAxios); + // The injected client's baseURL should be preserved, not overridden + expect(client.client.defaults.baseURL).toBe("https://mock.example.com"); + }); + }); +}); diff --git a/tests/unit/sanitization.test.ts b/tests/unit/sanitization.test.ts index dbc0843..8f60074 100644 --- a/tests/unit/sanitization.test.ts +++ b/tests/unit/sanitization.test.ts @@ -13,13 +13,13 @@ describe("Debug Logging Sanitization", () => { let mockClientInstance: any; let requestInterceptor: any; let responseInterceptorError: any; - + let debugSpy: any; let errorSpy: any; beforeEach(() => { jest.clearAllMocks(); - + // Spy on logger methods // We use mockImplementation to silence the console output during tests debugSpy = jest.spyOn(logger, "debug").mockImplementation(() => logger); @@ -48,15 +48,18 @@ describe("Debug Logging Sanitization", () => { }; if (jest.isMockFunction(mockedAxios.create)) { - mockedAxios.create.mockReturnValue(mockClientInstance); + mockedAxios.create.mockReturnValue(mockClientInstance); } else { - (mockedAxios as any).create = jest.fn().mockReturnValue(mockClientInstance); + (mockedAxios as any).create = jest + .fn() + .mockReturnValue(mockClientInstance); } }); it("should call axios.create and register interceptors", () => { new BaseIterableClient({ apiKey: "test-api-key", + baseUrl: "https://api.iterable.com", debug: true, }); @@ -68,6 +71,7 @@ describe("Debug Logging Sanitization", () => { it("should redact sensitive headers in debug logs", () => { new BaseIterableClient({ apiKey: "test-api-key", + baseUrl: "https://api.iterable.com", debug: true, }); @@ -78,7 +82,7 @@ describe("Debug Logging Sanitization", () => { url: "/test", headers: { Authorization: "Bearer secret-token", - "Cookie": "session=secret", + Cookie: "session=secret", "X-Custom": "safe", "Api-Key": "real-api-key", }, @@ -102,11 +106,13 @@ describe("Debug Logging Sanitization", () => { it("should NOT log error response data by default (debugVerbose=false)", async () => { new BaseIterableClient({ apiKey: "test-api-key", + baseUrl: "https://api.iterable.com", debug: true, debugVerbose: false, }); - if (!responseInterceptorError) throw new Error("Response interceptor missing"); + if (!responseInterceptorError) + throw new Error("Response interceptor missing"); const sensitiveError = { message: "User email@example.com not found" }; const errorResponse = { @@ -134,18 +140,20 @@ describe("Debug Logging Sanitization", () => { (call: any) => call[0] === "API error" ); const errorData = errorLog?.[1] as any; - + expect(errorData.data).toBeUndefined(); }); it("should log error response data when debugVerbose is true", async () => { new BaseIterableClient({ apiKey: "test-api-key", + baseUrl: "https://api.iterable.com", debug: true, debugVerbose: true, }); - if (!responseInterceptorError) throw new Error("Response interceptor missing"); + if (!responseInterceptorError) + throw new Error("Response interceptor missing"); const errorBody = { error: "details" }; const errorResponse = { diff --git a/tests/utils/test-helpers.ts b/tests/utils/test-helpers.ts index 8760e18..4eb9ad8 100644 --- a/tests/utils/test-helpers.ts +++ b/tests/utils/test-helpers.ts @@ -40,10 +40,15 @@ export function createMockClient(): { delete: jest.fn(), put: jest.fn(), patch: jest.fn(), + defaults: {}, + interceptors: { + request: { use: jest.fn(), eject: jest.fn(), clear: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn(), clear: jest.fn() }, + }, }; const client = new IterableClient( { apiKey: "test", baseUrl: "https://api.iterable.com" }, - mockAxiosInstance + mockAxiosInstance as any ); return { client, mockAxiosInstance }; }