Skip to content

Commit 1d0f822

Browse files
authored
Add constructor type and require baseUrl (#7)
1 parent 235eec9 commit 1d0f822

File tree

7 files changed

+149
-14
lines changed

7 files changed

+149
-14
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"engines": {
2626
"node": ">=18.0.0"
2727
},
28-
"packageManager": "pnpm@10.22.0",
28+
"packageManager": "pnpm@10.23.0",
2929
"publishConfig": {
3030
"access": "public"
3131
},

src/client/base.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export class BaseIterableClient {
5858
};
5959

6060
this.client = axios.create({
61-
baseURL: clientConfig.baseUrl || "https://api.iterable.com",
61+
baseURL: clientConfig.baseUrl,
6262
headers: {
6363
...defaultHeaders,
6464
...(clientConfig.customHeaders || {}),
@@ -84,7 +84,12 @@ export class BaseIterableClient {
8484
if (clientConfig.debug) {
8585
const sanitizeHeaders = (headers: any) => {
8686
if (!headers) return undefined;
87-
const sensitive = ["api-key", "authorization", "cookie", "set-cookie"];
87+
const sensitive = [
88+
"api-key",
89+
"authorization",
90+
"cookie",
91+
"set-cookie",
92+
];
8893
const sanitized = { ...headers };
8994
Object.keys(sanitized).forEach((key) => {
9095
if (sensitive.includes(key.toLowerCase())) {

src/client/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { AxiosInstance } from "axios";
2+
3+
import type { IterableConfig } from "../types/common.js";
14
import { BaseIterableClient } from "./base.js";
25
import { Campaigns } from "./campaigns.js";
36
import { Catalogs } from "./catalogs.js";
@@ -62,4 +65,14 @@ export class IterableClient extends compose(
6265
Templates,
6366
Users,
6467
Webhooks
65-
) {}
68+
) {
69+
/**
70+
* Create a new Iterable API client
71+
*
72+
* @param config - Optional configuration object. If not provided, will use environment variables
73+
* @param injectedClient - Optional pre-configured Axios instance for testing
74+
*/
75+
constructor(config?: IterableConfig, injectedClient?: AxiosInstance) {
76+
super(config, injectedClient);
77+
}
78+
}

src/types/common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export type IterableErrorResponse = z.infer<typeof IterableErrorResponseSchema>;
107107

108108
export const IterableConfigSchema = z.object({
109109
apiKey: z.string(),
110-
baseUrl: z.string().optional(),
110+
baseUrl: z.url(),
111111
timeout: z.number().optional(),
112112
debug: z.boolean().optional(),
113113
debugVerbose: z.boolean().optional(),

tests/unit/client.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { IterableClient } from "../../src/client/index.js";
2+
3+
describe("IterableClient", () => {
4+
describe("Constructor with baseUrl", () => {
5+
it("should use US endpoint when explicitly provided", () => {
6+
const client = new IterableClient({
7+
apiKey: "a1b2c3d4e5f6789012345678901234ab",
8+
baseUrl: "https://api.iterable.com",
9+
});
10+
11+
expect(client).toBeDefined();
12+
expect(client.client.defaults.baseURL).toBe("https://api.iterable.com");
13+
});
14+
15+
it("should use EU endpoint when explicitly provided", () => {
16+
const client = new IterableClient({
17+
apiKey: "a1b2c3d4e5f6789012345678901234ab",
18+
baseUrl: "https://api.eu.iterable.com",
19+
});
20+
21+
expect(client).toBeDefined();
22+
expect(client.client.defaults.baseURL).toBe(
23+
"https://api.eu.iterable.com"
24+
);
25+
});
26+
27+
it("should use custom endpoint when provided", () => {
28+
const client = new IterableClient({
29+
apiKey: "a1b2c3d4e5f6789012345678901234ab",
30+
baseUrl: "https://custom.api.example.com",
31+
});
32+
33+
expect(client).toBeDefined();
34+
expect(client.client.defaults.baseURL).toBe(
35+
"https://custom.api.example.com"
36+
);
37+
});
38+
39+
it("should accept all valid IterableConfig properties", () => {
40+
const client = new IterableClient({
41+
apiKey: "a1b2c3d4e5f6789012345678901234ab",
42+
baseUrl: "https://api.iterable.com",
43+
timeout: 45000,
44+
debug: false,
45+
debugVerbose: true,
46+
customHeaders: {
47+
"X-Custom-1": "value1",
48+
"X-Custom-2": "value2",
49+
},
50+
});
51+
52+
expect(client).toBeDefined();
53+
54+
// Verify axios configuration
55+
expect(client.client.defaults.baseURL).toBe("https://api.iterable.com");
56+
expect(client.client.defaults.timeout).toBe(45000);
57+
58+
// Verify API key is set in headers
59+
expect(client.client.defaults.headers["Api-Key"]).toBe(
60+
"a1b2c3d4e5f6789012345678901234ab"
61+
);
62+
63+
// Verify custom headers are merged into defaults
64+
expect(client.client.defaults.headers["X-Custom-1"]).toBe("value1");
65+
expect(client.client.defaults.headers["X-Custom-2"]).toBe("value2");
66+
67+
// Verify standard headers are still present
68+
expect(client.client.defaults.headers["Content-Type"]).toBe(
69+
"application/json"
70+
);
71+
expect(client.client.defaults.headers["User-Agent"]).toContain(
72+
"iterable-api"
73+
);
74+
});
75+
});
76+
77+
describe("Constructor with Injected Client", () => {
78+
it("should use the injected axios instance ignoring baseUrl config", () => {
79+
const mockAxios = {
80+
defaults: {
81+
// note this is the axios instance's baseURL, not the IterableConfig's baseUrl
82+
baseURL: "https://mock.example.com",
83+
},
84+
interceptors: {
85+
request: { use: jest.fn() },
86+
response: { use: jest.fn() },
87+
},
88+
} as any;
89+
90+
const client = new IterableClient(
91+
{
92+
apiKey: "a1b2c3d4e5f6789012345678901234ab",
93+
baseUrl: "https://api.iterable.com",
94+
},
95+
mockAxios
96+
);
97+
98+
expect(client).toBeDefined();
99+
expect(client.client).toBe(mockAxios);
100+
// The injected client's baseURL should be preserved, not overridden
101+
expect(client.client.defaults.baseURL).toBe("https://mock.example.com");
102+
});
103+
});
104+
});

tests/unit/sanitization.test.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ describe("Debug Logging Sanitization", () => {
1313
let mockClientInstance: any;
1414
let requestInterceptor: any;
1515
let responseInterceptorError: any;
16-
16+
1717
let debugSpy: any;
1818
let errorSpy: any;
1919

2020
beforeEach(() => {
2121
jest.clearAllMocks();
22-
22+
2323
// Spy on logger methods
2424
// We use mockImplementation to silence the console output during tests
2525
debugSpy = jest.spyOn(logger, "debug").mockImplementation(() => logger);
@@ -48,15 +48,18 @@ describe("Debug Logging Sanitization", () => {
4848
};
4949

5050
if (jest.isMockFunction(mockedAxios.create)) {
51-
mockedAxios.create.mockReturnValue(mockClientInstance);
51+
mockedAxios.create.mockReturnValue(mockClientInstance);
5252
} else {
53-
(mockedAxios as any).create = jest.fn().mockReturnValue(mockClientInstance);
53+
(mockedAxios as any).create = jest
54+
.fn()
55+
.mockReturnValue(mockClientInstance);
5456
}
5557
});
5658

5759
it("should call axios.create and register interceptors", () => {
5860
new BaseIterableClient({
5961
apiKey: "test-api-key",
62+
baseUrl: "https://api.iterable.com",
6063
debug: true,
6164
});
6265

@@ -68,6 +71,7 @@ describe("Debug Logging Sanitization", () => {
6871
it("should redact sensitive headers in debug logs", () => {
6972
new BaseIterableClient({
7073
apiKey: "test-api-key",
74+
baseUrl: "https://api.iterable.com",
7175
debug: true,
7276
});
7377

@@ -78,7 +82,7 @@ describe("Debug Logging Sanitization", () => {
7882
url: "/test",
7983
headers: {
8084
Authorization: "Bearer secret-token",
81-
"Cookie": "session=secret",
85+
Cookie: "session=secret",
8286
"X-Custom": "safe",
8387
"Api-Key": "real-api-key",
8488
},
@@ -102,11 +106,13 @@ describe("Debug Logging Sanitization", () => {
102106
it("should NOT log error response data by default (debugVerbose=false)", async () => {
103107
new BaseIterableClient({
104108
apiKey: "test-api-key",
109+
baseUrl: "https://api.iterable.com",
105110
debug: true,
106111
debugVerbose: false,
107112
});
108113

109-
if (!responseInterceptorError) throw new Error("Response interceptor missing");
114+
if (!responseInterceptorError)
115+
throw new Error("Response interceptor missing");
110116

111117
const sensitiveError = { message: "User [email protected] not found" };
112118
const errorResponse = {
@@ -134,18 +140,20 @@ describe("Debug Logging Sanitization", () => {
134140
(call: any) => call[0] === "API error"
135141
);
136142
const errorData = errorLog?.[1] as any;
137-
143+
138144
expect(errorData.data).toBeUndefined();
139145
});
140146

141147
it("should log error response data when debugVerbose is true", async () => {
142148
new BaseIterableClient({
143149
apiKey: "test-api-key",
150+
baseUrl: "https://api.iterable.com",
144151
debug: true,
145152
debugVerbose: true,
146153
});
147154

148-
if (!responseInterceptorError) throw new Error("Response interceptor missing");
155+
if (!responseInterceptorError)
156+
throw new Error("Response interceptor missing");
149157

150158
const errorBody = { error: "details" };
151159
const errorResponse = {

tests/utils/test-helpers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,15 @@ export function createMockClient(): {
4040
delete: jest.fn(),
4141
put: jest.fn(),
4242
patch: jest.fn(),
43+
defaults: {},
44+
interceptors: {
45+
request: { use: jest.fn(), eject: jest.fn(), clear: jest.fn() },
46+
response: { use: jest.fn(), eject: jest.fn(), clear: jest.fn() },
47+
},
4348
};
4449
const client = new IterableClient(
4550
{ apiKey: "test", baseUrl: "https://api.iterable.com" },
46-
mockAxiosInstance
51+
mockAxiosInstance as any
4752
);
4853
return { client, mockAxiosInstance };
4954
}

0 commit comments

Comments
 (0)