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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"engines": {
"node": ">=18.0.0"
},
"packageManager": "pnpm@10.22.0",
"packageManager": "pnpm@10.23.0",
"publishConfig": {
"access": "public"
},
Expand Down
9 changes: 7 additions & 2 deletions src/client/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {}),
Expand All @@ -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())) {
Expand Down
15 changes: 14 additions & 1 deletion src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export type IterableErrorResponse = z.infer<typeof IterableErrorResponseSchema>;

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(),
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/client.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
24 changes: 16 additions & 8 deletions tests/unit/sanitization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});

Expand All @@ -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,
});

Expand All @@ -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",
},
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
7 changes: 6 additions & 1 deletion tests/utils/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down