@hasna/connectors 1.3.45 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +25 -10
  2. package/bin/index.js +1362 -11796
  3. package/bin/mcp.js +751 -10328
  4. package/bin/serve.js +2252 -11829
  5. package/connectors/cloudflare-workers/.env.example +5 -0
  6. package/connectors/cloudflare-workers/AGENTS.md +11 -0
  7. package/connectors/cloudflare-workers/CLAUDE.md +11 -0
  8. package/connectors/cloudflare-workers/README.md +45 -0
  9. package/connectors/cloudflare-workers/package.json +47 -0
  10. package/connectors/cloudflare-workers/src/api/client.test.ts +144 -0
  11. package/connectors/cloudflare-workers/src/api/client.ts +386 -0
  12. package/connectors/cloudflare-workers/src/api/index.ts +1 -0
  13. package/connectors/cloudflare-workers/src/cli/index.ts +206 -0
  14. package/connectors/cloudflare-workers/src/index.ts +16 -0
  15. package/connectors/cloudflare-workers/src/types/index.ts +53 -0
  16. package/connectors/cloudflare-workers/src/utils/config.ts +61 -0
  17. package/connectors/cloudflare-workers/src/utils/output.ts +26 -0
  18. package/connectors/cloudflare-workers/tsconfig.build.json +4 -0
  19. package/connectors/cloudflare-workers/tsconfig.json +14 -0
  20. package/connectors/tidio/.env.example +3 -2
  21. package/connectors/tidio/CLAUDE.md +21 -96
  22. package/connectors/tidio/README.md +22 -18
  23. package/connectors/tidio/package.json +4 -2
  24. package/connectors/tidio/src/api/client.ts +26 -15
  25. package/connectors/tidio/src/api/index.ts +185 -128
  26. package/connectors/tidio/src/api/tidio.test.ts +175 -44
  27. package/connectors/tidio/src/cli/index.ts +154 -388
  28. package/connectors/tidio/src/index.ts +5 -3
  29. package/connectors/tidio/src/types/index.ts +135 -82
  30. package/connectors/tidio/src/utils/config.ts +90 -44
  31. package/connectors/wandb/.env.example +7 -0
  32. package/connectors/wandb/AGENTS.md +43 -0
  33. package/connectors/wandb/CLAUDE.md +67 -0
  34. package/connectors/wandb/README.md +65 -0
  35. package/connectors/wandb/package.json +52 -0
  36. package/connectors/wandb/src/api/client.test.ts +116 -0
  37. package/connectors/wandb/src/api/client.ts +83 -0
  38. package/connectors/wandb/src/api/graphql.ts +9 -0
  39. package/connectors/wandb/src/api/index.ts +36 -0
  40. package/connectors/wandb/src/api/projects.ts +36 -0
  41. package/connectors/wandb/src/api/viewer.ts +20 -0
  42. package/connectors/wandb/src/cli/index.ts +265 -0
  43. package/connectors/wandb/src/index.ts +22 -0
  44. package/connectors/wandb/src/types/index.ts +75 -0
  45. package/connectors/wandb/src/utils/config.ts +164 -0
  46. package/connectors/wandb/src/utils/output.ts +119 -0
  47. package/connectors/wandb/tsconfig.json +16 -0
  48. package/connectors/weights-biases/.env.example +7 -0
  49. package/connectors/weights-biases/CLAUDE.md +65 -0
  50. package/connectors/weights-biases/README.md +62 -0
  51. package/connectors/weights-biases/package.json +53 -0
  52. package/connectors/weights-biases/src/api/client.test.ts +46 -0
  53. package/connectors/weights-biases/src/api/client.ts +119 -0
  54. package/connectors/weights-biases/src/api/events.ts +18 -0
  55. package/connectors/weights-biases/src/api/index.ts +56 -0
  56. package/connectors/weights-biases/src/api/runs.ts +28 -0
  57. package/connectors/weights-biases/src/api/search.ts +10 -0
  58. package/connectors/weights-biases/src/cli/index.ts +289 -0
  59. package/connectors/weights-biases/src/index.ts +19 -0
  60. package/connectors/weights-biases/src/types/index.ts +92 -0
  61. package/connectors/weights-biases/src/utils/config.ts +168 -0
  62. package/connectors/weights-biases/src/utils/output.ts +116 -0
  63. package/connectors/weights-biases/tsconfig.json +16 -0
  64. package/connectors/x/src/api/oauth.test.ts +205 -0
  65. package/connectors/zoominfo/.env.example +4 -0
  66. package/connectors/zoominfo/AGENTS.md +26 -0
  67. package/connectors/zoominfo/CLAUDE.md +20 -0
  68. package/connectors/zoominfo/README.md +69 -0
  69. package/connectors/zoominfo/package.json +52 -0
  70. package/connectors/zoominfo/src/api/client.test.ts +140 -0
  71. package/connectors/zoominfo/src/api/client.ts +228 -0
  72. package/connectors/zoominfo/src/api/index.ts +1 -0
  73. package/connectors/zoominfo/src/cli/index.ts +179 -0
  74. package/connectors/zoominfo/src/index.ts +3 -0
  75. package/connectors/zoominfo/src/types/index.ts +39 -0
  76. package/connectors/zoominfo/src/utils/config.ts +61 -0
  77. package/connectors/zoominfo/src/utils/output.ts +27 -0
  78. package/connectors/zoominfo/tsconfig.json +20 -0
  79. package/connectors/zotero/.env.example +7 -0
  80. package/connectors/zotero/.npmrc.example +2 -0
  81. package/connectors/zotero/AGENTS.md +40 -0
  82. package/connectors/zotero/CLAUDE.md +69 -0
  83. package/connectors/zotero/README.md +91 -0
  84. package/connectors/zotero/package.json +43 -0
  85. package/connectors/zotero/src/api/attachments.ts +130 -0
  86. package/connectors/zotero/src/api/client.test.ts +166 -0
  87. package/connectors/zotero/src/api/client.ts +169 -0
  88. package/connectors/zotero/src/api/collections.ts +26 -0
  89. package/connectors/zotero/src/api/index.ts +45 -0
  90. package/connectors/zotero/src/api/items.ts +64 -0
  91. package/connectors/zotero/src/cli/index.ts +386 -0
  92. package/connectors/zotero/src/index.ts +28 -0
  93. package/connectors/zotero/src/types/index.ts +129 -0
  94. package/connectors/zotero/src/utils/config.ts +245 -0
  95. package/connectors/zotero/src/utils/output.ts +119 -0
  96. package/connectors/zotero/tsconfig.json +16 -0
  97. package/connectors/zymbly/.env.example +2 -0
  98. package/connectors/zymbly/AGENTS.md +11 -0
  99. package/connectors/zymbly/CLAUDE.md +5 -0
  100. package/connectors/zymbly/README.md +35 -0
  101. package/connectors/zymbly/package.json +52 -0
  102. package/connectors/zymbly/src/api/client.test.ts +110 -0
  103. package/connectors/zymbly/src/api/client.ts +166 -0
  104. package/connectors/zymbly/src/api/index.ts +1 -0
  105. package/connectors/zymbly/src/cli/index.ts +156 -0
  106. package/connectors/zymbly/src/index.ts +12 -0
  107. package/connectors/zymbly/src/types/index.ts +34 -0
  108. package/connectors/zymbly/src/utils/config.ts +52 -0
  109. package/connectors/zymbly/src/utils/output.ts +17 -0
  110. package/connectors/zymbly/tsconfig.json +13 -0
  111. package/dist/db/database.d.ts +2 -1
  112. package/dist/db/sqlite-adapter.d.ts +43 -0
  113. package/dist/index.js +172 -9653
  114. package/dist/lib/compact-output.d.ts +40 -0
  115. package/dist/mcp/tools/jobs.d.ts +32 -0
  116. package/dist/mcp/tools/jobs.test.d.ts +1 -0
  117. package/dist/no-cloud-boundary.test.d.ts +1 -0
  118. package/package.json +3 -3
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import {
3
+ exchangeCodeForTokens,
4
+ refreshAccessToken,
5
+ revokeToken,
6
+ type OAuth2Config,
7
+ } from './oauth';
8
+
9
+ /**
10
+ * Regression tests for https://github.com/hasna/connectors/issues/1
11
+ *
12
+ * X rejects `Authorization: Basic <client_id:client_secret>` on
13
+ * POST /2/oauth2/token when the app is registered as a *public* client:
14
+ * {"error":"unauthorized_client",
15
+ * "error_description":"Missing valid authorization header"}
16
+ *
17
+ * The connector therefore must always authenticate the client with POST body
18
+ * parameters (`client_id`, plus `client_secret` when one is configured) and
19
+ * must never fall back to an Authorization header on the token/revoke
20
+ * endpoints.
21
+ */
22
+
23
+ interface CapturedRequest {
24
+ url: string;
25
+ method?: string;
26
+ headers: Record<string, string>;
27
+ body: URLSearchParams;
28
+ }
29
+
30
+ const realFetch = globalThis.fetch;
31
+
32
+ /**
33
+ * Normalise every shape `RequestInit.headers` can take (plain object, entry
34
+ * array, `Headers` instance) into a lower-cased record. Going through
35
+ * `new Headers(...)` matters: if the implementation ever switched to a
36
+ * `Headers` object, a naive `Object.entries()` would yield `[]` and the
37
+ * "no Authorization header" assertions below would pass vacuously.
38
+ */
39
+ function normaliseHeaders(init?: RequestInit): Record<string, string> {
40
+ const out: Record<string, string> = {};
41
+ if (!init?.headers) return out;
42
+ new Headers(init.headers).forEach((value, key) => {
43
+ out[key.toLowerCase()] = value;
44
+ });
45
+ return out;
46
+ }
47
+
48
+ function captureTokenRequest(): { calls: CapturedRequest[] } {
49
+ const calls: CapturedRequest[] = [];
50
+
51
+ globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
52
+ const headers = normaliseHeaders(init);
53
+
54
+ calls.push({
55
+ url: String(input),
56
+ method: init?.method,
57
+ headers,
58
+ body: new URLSearchParams(String(init?.body ?? '')),
59
+ });
60
+
61
+ return new Response(
62
+ JSON.stringify({
63
+ access_token: 'ACCESS_TOKEN',
64
+ refresh_token: 'NEW_REFRESH_TOKEN',
65
+ expires_in: 7200,
66
+ scope: 'tweet.read tweet.write offline.access',
67
+ token_type: 'bearer',
68
+ }),
69
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
70
+ );
71
+ }) as typeof fetch;
72
+
73
+ return { calls };
74
+ }
75
+
76
+ const PUBLIC_CLIENT: OAuth2Config = {
77
+ clientId: 'PUBLIC_CLIENT_ID',
78
+ redirectUri: 'http://localhost:8888/callback',
79
+ };
80
+
81
+ const CONFIDENTIAL_CLIENT: OAuth2Config = {
82
+ clientId: 'CONFIDENTIAL_CLIENT_ID',
83
+ clientSecret: 'CONFIDENTIAL_CLIENT_SECRET',
84
+ redirectUri: 'http://localhost:8888/callback',
85
+ };
86
+
87
+ afterEach(() => {
88
+ globalThis.fetch = realFetch;
89
+ });
90
+
91
+ describe('OAuth 2.0 token requests - public client (issue #1)', () => {
92
+ test('exchangeCodeForTokens sends client_id in the body and no auth header', async () => {
93
+ const { calls } = captureTokenRequest();
94
+
95
+ const tokens = await exchangeCodeForTokens(
96
+ PUBLIC_CLIENT,
97
+ 'AUTH_CODE',
98
+ 'CODE_VERIFIER'
99
+ );
100
+
101
+ expect(calls).toHaveLength(1);
102
+ const req = calls[0]!;
103
+ expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
104
+ expect(req.method).toBe('POST');
105
+ expect(req.headers['authorization']).toBeUndefined();
106
+ expect(req.headers['content-type']).toBe(
107
+ 'application/x-www-form-urlencoded'
108
+ );
109
+ expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
110
+ expect(req.body.get('client_secret')).toBeNull();
111
+ expect(req.body.get('grant_type')).toBe('authorization_code');
112
+ expect(req.body.get('code')).toBe('AUTH_CODE');
113
+ expect(req.body.get('code_verifier')).toBe('CODE_VERIFIER');
114
+ expect(req.body.get('redirect_uri')).toBe(
115
+ 'http://localhost:8888/callback'
116
+ );
117
+
118
+ expect(tokens.accessToken).toBe('ACCESS_TOKEN');
119
+ expect(tokens.refreshToken).toBe('NEW_REFRESH_TOKEN');
120
+ });
121
+
122
+ test('refreshAccessToken sends client_id in the body and no auth header', async () => {
123
+ const { calls } = captureTokenRequest();
124
+
125
+ await refreshAccessToken(PUBLIC_CLIENT, 'OLD_REFRESH_TOKEN');
126
+
127
+ expect(calls).toHaveLength(1);
128
+ const req = calls[0]!;
129
+ expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
130
+ expect(req.headers['authorization']).toBeUndefined();
131
+ expect(req.body.get('grant_type')).toBe('refresh_token');
132
+ expect(req.body.get('refresh_token')).toBe('OLD_REFRESH_TOKEN');
133
+ expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
134
+ expect(req.body.get('client_secret')).toBeNull();
135
+ });
136
+
137
+ test('revokeToken sends client_id in the body and no auth header', async () => {
138
+ const { calls } = captureTokenRequest();
139
+
140
+ await revokeToken(PUBLIC_CLIENT, 'SOME_ACCESS_TOKEN', 'access_token');
141
+
142
+ expect(calls).toHaveLength(1);
143
+ const req = calls[0]!;
144
+ expect(req.url).toBe('https://api.twitter.com/2/oauth2/revoke');
145
+ expect(req.headers['authorization']).toBeUndefined();
146
+ expect(req.body.get('token')).toBe('SOME_ACCESS_TOKEN');
147
+ expect(req.body.get('token_type_hint')).toBe('access_token');
148
+ expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
149
+ });
150
+ });
151
+
152
+ describe('OAuth 2.0 token requests - configured client secret (issue #1)', () => {
153
+ // This is the exact trigger of issue #1: a client_secret is present in the
154
+ // connector config (env var or ~/.hasna/connectors/connect-x/credentials.json)
155
+ // while the X app itself is registered as a public client. Sending Basic auth
156
+ // in that situation is what produced "Missing valid authorization header".
157
+ test('never falls back to Basic auth when a client secret is configured', async () => {
158
+ const { calls } = captureTokenRequest();
159
+
160
+ await exchangeCodeForTokens(
161
+ CONFIDENTIAL_CLIENT,
162
+ 'AUTH_CODE',
163
+ 'CODE_VERIFIER'
164
+ );
165
+ await refreshAccessToken(CONFIDENTIAL_CLIENT, 'OLD_REFRESH_TOKEN');
166
+ await revokeToken(CONFIDENTIAL_CLIENT, 'SOME_ACCESS_TOKEN');
167
+
168
+ expect(calls).toHaveLength(3);
169
+ for (const req of calls) {
170
+ expect(req.headers['authorization']).toBeUndefined();
171
+ expect(req.body.get('client_id')).toBe('CONFIDENTIAL_CLIENT_ID');
172
+ }
173
+ });
174
+
175
+ test('still authenticates the client via client_secret_post', async () => {
176
+ const { calls } = captureTokenRequest();
177
+
178
+ await exchangeCodeForTokens(
179
+ CONFIDENTIAL_CLIENT,
180
+ 'AUTH_CODE',
181
+ 'CODE_VERIFIER'
182
+ );
183
+
184
+ expect(calls[0]!.body.get('client_secret')).toBe(
185
+ 'CONFIDENTIAL_CLIENT_SECRET'
186
+ );
187
+ });
188
+ });
189
+
190
+ describe('OAuth 2.0 token requests - error surfacing', () => {
191
+ test('exchangeCodeForTokens surfaces the X error payload', async () => {
192
+ globalThis.fetch = (async () =>
193
+ new Response(
194
+ JSON.stringify({
195
+ error: 'unauthorized_client',
196
+ error_description: 'Missing valid authorization header',
197
+ }),
198
+ { status: 400 }
199
+ )) as typeof fetch;
200
+
201
+ await expect(
202
+ exchangeCodeForTokens(PUBLIC_CLIENT, 'AUTH_CODE', 'CODE_VERIFIER')
203
+ ).rejects.toThrow(/Token exchange failed:.*unauthorized_client/s);
204
+ });
205
+ });
@@ -0,0 +1,4 @@
1
+ ZOOMINFO_USERNAME=your_zoominfo_username
2
+ ZOOMINFO_PASSWORD=your_zoominfo_password
3
+ ZOOMINFO_JWT=your_zoominfo_jwt
4
+ ZOOMINFO_BASE_URL=https://api.zoominfo.com
@@ -0,0 +1,26 @@
1
+ # AGENTS.md
2
+
3
+ Guidance for AI agents working on this connector.
4
+
5
+ ## Scope
6
+
7
+ This package implements `@hasna/connect-zoominfo`, a real ZoomInfo B2B sales intelligence REST API connector. Keep it on documented ZoomInfo HTTP endpoints and do not add browser automation dependencies.
8
+
9
+ ## API Model
10
+
11
+ - Default base URL: `https://api.zoominfo.com`
12
+ - Authentication: `POST /authenticate` with username/password returns a JWT, or use a preconfigured JWT via `ZOOMINFO_JWT`
13
+ - Subsequent requests use `Authorization: Bearer <jwt>`
14
+ - Raw requests must use relative paths without `..` traversal
15
+ - Keep `.env.example` values as placeholders only
16
+
17
+ ## Checks
18
+
19
+ Before finishing changes:
20
+
21
+ ```bash
22
+ bun run typecheck
23
+ bun run build
24
+ ```
25
+
26
+ Also scan for committed secrets, internal references, and browser automation dependencies.
@@ -0,0 +1,20 @@
1
+ # CLAUDE.md
2
+
3
+ See `AGENTS.md` for connector-specific instructions.
4
+
5
+ ## Authentication
6
+
7
+ ZoomInfo uses username/password authentication via `POST /authenticate` to obtain a Bearer JWT token. Preconfigured JWT tokens are also supported via `ZOOMINFO_JWT` or `connect-zoominfo config set-jwt`.
8
+
9
+ Subsequent API calls use `Authorization: Bearer <jwt>`.
10
+
11
+ Environment variables:
12
+
13
+ | Variable | Description |
14
+ |----------|-------------|
15
+ | `ZOOMINFO_USERNAME` | ZoomInfo API username |
16
+ | `ZOOMINFO_PASSWORD` | ZoomInfo API password |
17
+ | `ZOOMINFO_JWT` | Preconfigured Bearer JWT (skips /authenticate) |
18
+ | `ZOOMINFO_BASE_URL` | Override API base URL |
19
+
20
+ JWT tokens expire after approximately 60 minutes. Long-running processes should re-authenticate or refresh the configured JWT.
@@ -0,0 +1,69 @@
1
+ # @hasna/connect-zoominfo
2
+
3
+ TypeScript and CLI connector for the ZoomInfo B2B sales intelligence API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @hasna/connect-zoominfo
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ ```bash
14
+ export ZOOMINFO_USERNAME=your_zoominfo_username
15
+ export ZOOMINFO_PASSWORD=your_zoominfo_password
16
+ export ZOOMINFO_JWT=your_zoominfo_jwt
17
+ export ZOOMINFO_BASE_URL=https://api.zoominfo.com
18
+ ```
19
+
20
+ The CLI can also store local configuration:
21
+
22
+ ```bash
23
+ connect-zoominfo config set-username your_zoominfo_username
24
+ connect-zoominfo config set-password your_zoominfo_password
25
+ connect-zoominfo config set-jwt your_zoominfo_jwt
26
+ connect-zoominfo config show
27
+ ```
28
+
29
+ ## CLI Examples
30
+
31
+ ```bash
32
+ connect-zoominfo authenticate
33
+ connect-zoominfo search-contacts --body '{"jobTitle":["VP of Sales"],"rpp":25,"page":1}'
34
+ connect-zoominfo search-companies --body '{"companyName":"Example Corp"}'
35
+ connect-zoominfo enrich-contact --body '{"matchPersonInput":[{"emailAddress":"person@example.com"}],"outputFields":["email","jobTitle"]}'
36
+ connect-zoominfo enrich-company --body '{"matchCompanyInput":[{"companyName":"Example Corp"}]}'
37
+ connect-zoominfo lookup-contact 123456789
38
+ connect-zoominfo contact-search-output-fields
39
+ connect-zoominfo raw-request POST /search/contact --body '{"jobTitle":["CTO"]}'
40
+ ```
41
+
42
+ ## TypeScript
43
+
44
+ ```ts
45
+ import { ZoomInfo } from "@hasna/connect-zoominfo";
46
+
47
+ const zoominfo = new ZoomInfo({
48
+ username: process.env.ZOOMINFO_USERNAME,
49
+ password: process.env.ZOOMINFO_PASSWORD,
50
+ });
51
+
52
+ await zoominfo.authenticate();
53
+ const contacts = await zoominfo.searchContacts({ jobTitle: ["VP of Sales"], rpp: 25, page: 1 });
54
+ const enriched = await zoominfo.enrichContact({
55
+ matchPersonInput: [{ emailAddress: "person@example.com" }],
56
+ outputFields: ["email", "jobTitle"],
57
+ });
58
+ ```
59
+
60
+ ## API Coverage
61
+
62
+ - Username/password authentication (`POST /authenticate`) with JWT caching per client instance
63
+ - Contact and company search
64
+ - Contact and company enrichment
65
+ - Contact lookup by ID
66
+ - Search output field discovery
67
+ - Relative raw requests
68
+
69
+ JWT tokens are valid for approximately 60 minutes. Long-running processes may need to re-authenticate or provide a fresh `ZOOMINFO_JWT`.
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@hasna/connect-zoominfo",
3
+ "version": "0.0.1",
4
+ "description": "ZoomInfo B2B sales intelligence API connector for contact and company search, enrichment, and lookup",
5
+ "type": "module",
6
+ "bin": {
7
+ "connect-zoominfo": "./bin/index.js"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "scripts": {
18
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun && bun build ./src/cli/index.ts --outdir ./bin --target bun",
19
+ "dev": "bun run ./src/cli/index.ts",
20
+ "typecheck": "tsc --noEmit",
21
+ "prepublishOnly": "bun run build"
22
+ },
23
+ "keywords": [
24
+ "zoominfo",
25
+ "sales-intelligence",
26
+ "enrichment",
27
+ "contacts",
28
+ "companies",
29
+ "b2b",
30
+ "api",
31
+ "connector",
32
+ "cli",
33
+ "typescript",
34
+ "bun"
35
+ ],
36
+ "author": "Hasna",
37
+ "license": "Apache-2.0",
38
+ "devDependencies": {
39
+ "@types/bun": "latest",
40
+ "typescript": "^5"
41
+ },
42
+ "dependencies": {
43
+ "commander": "^12.1.0"
44
+ },
45
+ "engines": {
46
+ "bun": ">=1.0.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/hasna/connect-zoominfo.git"
51
+ }
52
+ }
@@ -0,0 +1,140 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { ZoomInfo, ZoomInfoApiError } from "../index";
3
+
4
+ const originalFetch = globalThis.fetch;
5
+ let fetchMock: ReturnType<typeof mock>;
6
+
7
+ function mockJsonResponse(body: unknown, init: ResponseInit = {}) {
8
+ return new Response(JSON.stringify(body), {
9
+ status: init.status ?? 200,
10
+ headers: { "content-type": "application/json", ...init.headers },
11
+ });
12
+ }
13
+
14
+ function clientWithCredentials() {
15
+ return new ZoomInfo({
16
+ username: "api-user",
17
+ password: "api-pass",
18
+ baseUrl: "https://api.zoominfo.com",
19
+ });
20
+ }
21
+
22
+ function clientWithJwt() {
23
+ return new ZoomInfo({
24
+ jwt: "configured-jwt-token",
25
+ baseUrl: "https://custom.zoominfo.test",
26
+ });
27
+ }
28
+
29
+ beforeEach(() => {
30
+ fetchMock = mock(async () => mockJsonResponse({ success: true }));
31
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
32
+ });
33
+
34
+ afterEach(() => {
35
+ globalThis.fetch = originalFetch;
36
+ });
37
+
38
+ describe("ZoomInfoClient", () => {
39
+ test("authenticates with username/password and caches JWT for subsequent calls", async () => {
40
+ fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
41
+ const url = String(input);
42
+ if (url.endsWith("/authenticate")) {
43
+ return mockJsonResponse({ jwt: "fresh-jwt-token" });
44
+ }
45
+ if (url.endsWith("/search/contact")) {
46
+ const headers = init?.headers as Record<string, string>;
47
+ expect(headers.Authorization).toBe("Bearer fresh-jwt-token");
48
+ return mockJsonResponse({ data: [{ id: "1" }] });
49
+ }
50
+ return mockJsonResponse({});
51
+ });
52
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
53
+
54
+ const api = clientWithCredentials();
55
+ const auth = await api.authenticate();
56
+ expect(auth.jwt).toBe("fresh-jwt-token");
57
+
58
+ await api.searchContacts({ jobTitle: ["VP of Sales"] });
59
+
60
+ expect(fetchMock).toHaveBeenCalledTimes(2);
61
+ const [authUrl, authInit] = fetchMock.mock.calls[0] as [string, RequestInit];
62
+ expect(authUrl).toBe("https://api.zoominfo.com/authenticate");
63
+ expect(authInit.method).toBe("POST");
64
+ expect(JSON.parse(String(authInit.body))).toEqual({ username: "api-user", password: "api-pass" });
65
+ });
66
+
67
+ test("configured JWT skips /authenticate on API calls", async () => {
68
+ const api = clientWithJwt();
69
+
70
+ await api.searchCompanies({ companyName: "Example Corp" });
71
+
72
+ expect(fetchMock).toHaveBeenCalledTimes(1);
73
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
74
+ expect(url).toBe("https://custom.zoominfo.test/search/company");
75
+ expect(init.method).toBe("POST");
76
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer configured-jwt-token");
77
+ });
78
+
79
+ test("lookupContact uses bearer auth and encodes contact id", async () => {
80
+ const api = clientWithJwt();
81
+
82
+ await api.lookupContact("contact/id");
83
+
84
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
85
+ expect(url).toBe("https://custom.zoominfo.test/lookup/contact/contact%2Fid");
86
+ expect(init.method).toBe("GET");
87
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer configured-jwt-token");
88
+ });
89
+
90
+ test("enrichContact requires matchPersonInput", async () => {
91
+ const api = clientWithJwt();
92
+
93
+ await expect(api.enrichContact({ outputFields: ["email"] })).rejects.toThrow("matchPersonInput");
94
+ expect(fetchMock).not.toHaveBeenCalled();
95
+ });
96
+
97
+ test("enrichContact sends matchPersonInput payload", async () => {
98
+ const api = clientWithJwt();
99
+ const body = {
100
+ matchPersonInput: [{ emailAddress: "person@example.com" }],
101
+ outputFields: ["email", "jobTitle"],
102
+ };
103
+
104
+ await api.enrichContact(body);
105
+
106
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
107
+ expect(init.method).toBe("POST");
108
+ expect(JSON.parse(String(init.body))).toEqual(body);
109
+ });
110
+
111
+ test("lists contact and company search output fields", async () => {
112
+ const api = clientWithJwt();
113
+
114
+ await api.listContactSearchOutputFields();
115
+ await api.listCompanySearchOutputFields();
116
+
117
+ const urls = (fetchMock.mock.calls as [string, RequestInit][]).map(([url]) => url);
118
+ expect(urls).toEqual([
119
+ "https://custom.zoominfo.test/lookup/outputfields/contact/search",
120
+ "https://custom.zoominfo.test/lookup/outputfields/company/search",
121
+ ]);
122
+ });
123
+
124
+ test("rawRequest rejects absolute URLs and path traversal", async () => {
125
+ const api = clientWithJwt();
126
+
127
+ await expect(api.rawRequest("GET", "https://evil.example/search/contact")).rejects.toThrow("absolute URLs");
128
+ await expect(api.rawRequest("GET", "/search/../admin")).rejects.toThrow("path traversal");
129
+ expect(fetchMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ test("throws ZoomInfoApiError on HTTP failures", async () => {
133
+ fetchMock = mock(async () => mockJsonResponse({ message: "Unauthorized" }, { status: 401 }));
134
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
135
+
136
+ const api = clientWithJwt();
137
+
138
+ await expect(api.searchContacts()).rejects.toBeInstanceOf(ZoomInfoApiError);
139
+ });
140
+ });